]> git.evergreen-ils.org Git - Evergreen.git/blob - Open-ILS/src/perlmods/lib/OpenILS/Application/Circ/Holds.pm
LP1748265 Hold fetch API sms_carrier option
[Evergreen.git] / Open-ILS / src / perlmods / lib / OpenILS / Application / Circ / Holds.pm
1 # ---------------------------------------------------------------
2 # Copyright (C) 2005  Georgia Public Library Service
3 # Bill Erickson <highfalutin@gmail.com>
4
5 # This program is free software; you can redistribute it and/or
6 # modify it under the terms of the GNU General Public License
7 # as published by the Free Software Foundation; either version 2
8 # of the License, or (at your option) any later version.
9
10 # This program is distributed in the hope that it will be useful,
11 # but WITHOUT ANY WARRANTY; without even the implied warranty of
12 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
13 # GNU General Public License for more details.
14 # ---------------------------------------------------------------
15
16
17 package OpenILS::Application::Circ::Holds;
18 use base qw/OpenILS::Application/;
19 use strict; use warnings;
20 use OpenILS::Application::AppUtils;
21 use DateTime;
22 use Data::Dumper;
23 use OpenSRF::EX qw(:try);
24 use OpenILS::Perm;
25 use OpenILS::Event;
26 use OpenSRF::Utils;
27 use OpenSRF::Utils::Logger qw(:logger);
28 use OpenILS::Utils::CStoreEditor q/:funcs/;
29 use OpenILS::Utils::PermitHold;
30 use OpenSRF::Utils::SettingsClient;
31 use OpenILS::Const qw/:const/;
32 use OpenILS::Application::Circ::Transit;
33 use OpenILS::Application::Actor::Friends;
34 use DateTime;
35 use DateTime::Format::ISO8601;
36 use OpenILS::Utils::DateTime qw/:datetime/;
37 use Digest::MD5 qw(md5_hex);
38 use OpenSRF::Utils::Cache;
39 use OpenSRF::Utils::JSON;
40 my $apputils = "OpenILS::Application::AppUtils";
41 my $U = $apputils;
42
43 __PACKAGE__->register_method(
44     method    => "test_and_create_hold_batch",
45     api_name  => "open-ils.circ.holds.test_and_create.batch",
46     stream => 1,
47     signature => {
48         desc => q/This is for batch creating a set of holds where every field is identical except for the targets./,
49         params => [
50             { desc => 'Authentication token', type => 'string' },
51             { desc => 'Hash of named parameters.  Same as for open-ils.circ.title_hold.is_possible, though the pertinent target field is automatically populated based on the hold_type and the specified list of targets.', type => 'object'},
52             { desc => 'Array of target ids', type => 'array' }
53         ],
54         return => {
55             desc => 'Array of hold ID on success, -1 on missing arg, event (or ref to array of events) on error(s)',
56         },
57     }
58 );
59
60 __PACKAGE__->register_method(
61     method    => "test_and_create_hold_batch",
62     api_name  => "open-ils.circ.holds.test_and_create.batch.override",
63     stream => 1,
64     signature => {
65         desc  => '@see open-ils.circ.holds.test_and_create.batch',
66     }
67 );
68
69
70 sub test_and_create_hold_batch {
71     my( $self, $conn, $auth, $params, $target_list, $oargs ) = @_;
72
73     my $override = 0;
74     if ($self->api_name =~ /override/) {
75         $override = 1;
76         $oargs = { all => 1 } unless defined $oargs;
77         $$params{oargs} = $oargs; # for is_possible checking.
78     }
79
80     my $e = new_editor(authtoken=>$auth);
81     return $e->die_event unless $e->checkauth;
82     $$params{'requestor'} = $e->requestor->id;
83
84     my $target_field;
85     if ($$params{'hold_type'} eq 'T') { $target_field = 'titleid'; }
86     elsif ($$params{'hold_type'} eq 'C') { $target_field = 'copy_id'; }
87     elsif ($$params{'hold_type'} eq 'R') { $target_field = 'copy_id'; }
88     elsif ($$params{'hold_type'} eq 'F') { $target_field = 'copy_id'; }
89     elsif ($$params{'hold_type'} eq 'I') { $target_field = 'issuanceid'; }
90     elsif ($$params{'hold_type'} eq 'V') { $target_field = 'volume_id'; }
91     elsif ($$params{'hold_type'} eq 'M') { $target_field = 'mrid'; }
92     elsif ($$params{'hold_type'} eq 'P') { $target_field = 'partid'; }
93     else { return undef; }
94
95     my $formats_map = delete $$params{holdable_formats_map};
96
97     foreach (@$target_list) {
98         $$params{$target_field} = $_;
99
100         # copy the requested formats from the target->formats map
101         # into the top-level formats attr for each hold
102         $$params{holdable_formats} = $formats_map->{$_};
103
104         my $res;
105         ($res) = $self->method_lookup(
106             'open-ils.circ.title_hold.is_possible')->run($auth, $params, $override ? $oargs : {});
107         if ($res->{'success'} == 1) {
108
109             $params->{'depth'} = $res->{'depth'} if $res->{'depth'};
110
111             # Remove oargs from params so holds can be created.
112             if ($$params{oargs}) {
113                 delete $$params{oargs};
114             }
115
116             my $ahr = construct_hold_request_object($params);
117             my ($res2) = $self->method_lookup(
118                 $override
119                 ? 'open-ils.circ.holds.create.override'
120                 : 'open-ils.circ.holds.create'
121             )->run($auth, $ahr, $oargs);
122             $res2 = {
123                 'target' => $$params{$target_field},
124                 'result' => $res2
125             };
126             $conn->respond($res2);
127         } else {
128             $res = {
129                 'target' => $$params{$target_field},
130                 'result' => $res
131             };
132             $conn->respond($res);
133         }
134     }
135     return undef;
136 }
137
138 sub construct_hold_request_object {
139     my ($params) = @_;
140
141     my $ahr = Fieldmapper::action::hold_request->new;
142     $ahr->isnew('1');
143
144     foreach my $field (keys %{ $params }) {
145         if ($field eq 'depth') { $ahr->selection_depth($$params{$field}); }
146         elsif ($field eq 'patronid') {
147             $ahr->usr($$params{$field}); }
148         elsif ($field eq 'titleid') { $ahr->target($$params{$field}); }
149         elsif ($field eq 'copy_id') { $ahr->target($$params{$field}); }
150         elsif ($field eq 'issuanceid') { $ahr->target($$params{$field}); }
151         elsif ($field eq 'volume_id') { $ahr->target($$params{$field}); }
152         elsif ($field eq 'mrid') { $ahr->target($$params{$field}); }
153         elsif ($field eq 'partid') { $ahr->target($$params{$field}); }
154         else {
155             $ahr->$field($$params{$field});
156         }
157     }
158     return $ahr;
159 }
160
161 __PACKAGE__->register_method(
162     method    => "create_hold_batch",
163     api_name  => "open-ils.circ.holds.create.batch",
164     stream => 1,
165     signature => {
166         desc => q/@see open-ils.circ.holds.create.batch/,
167         params => [
168             { desc => 'Authentication token', type => 'string' },
169             { desc => 'Array of hold objects', type => 'array' }
170         ],
171         return => {
172             desc => 'Array of hold ID on success, -1 on missing arg, event (or ref to array of events) on error(s)',
173         },
174     }
175 );
176
177 __PACKAGE__->register_method(
178     method    => "create_hold_batch",
179     api_name  => "open-ils.circ.holds.create.override.batch",
180     stream => 1,
181     signature => {
182         desc  => '@see open-ils.circ.holds.create.batch',
183     }
184 );
185
186
187 sub create_hold_batch {
188     my( $self, $conn, $auth, $hold_list, $oargs ) = @_;
189     (my $method = $self->api_name) =~ s/\.batch//og;
190     foreach (@$hold_list) {
191         my ($res) = $self->method_lookup($method)->run($auth, $_, $oargs);
192         $conn->respond($res);
193     }
194     return undef;
195 }
196
197
198 __PACKAGE__->register_method(
199     method    => "create_hold",
200     api_name  => "open-ils.circ.holds.create",
201     signature => {
202         desc => "Create a new hold for an item.  From a permissions perspective, " .
203                 "the login session is used as the 'requestor' of the hold.  "      .
204                 "The hold recipient is determined by the 'usr' setting within the hold object. " .
205                 'First we verify the requestor has holds request permissions.  '         .
206                 'Then we verify that the recipient is allowed to make the given hold.  ' .
207                 'If not, we see if the requestor has "override" capabilities.  If not, ' .
208                 'a permission exception is returned.  If permissions allow, we cycle '   .
209                 'through the set of holds objects and create.  '                         .
210                 'If the recipient does not have permission to place multiple holds '     .
211                 'on a single title and said operation is attempted, a permission '       .
212                 'exception is returned',
213         params => [
214             { desc => 'Authentication token',               type => 'string' },
215             { desc => 'Hold object for hold to be created',
216                 type => 'object', class => 'ahr' }
217         ],
218         return => {
219             desc => 'New ahr ID on success, -1 on missing arg, event (or ref to array of events) on error(s)',
220         },
221     }
222 );
223
224 __PACKAGE__->register_method(
225     method    => "create_hold",
226     api_name  => "open-ils.circ.holds.create.override",
227     notes     => '@see open-ils.circ.holds.create',
228     signature => {
229         desc  => "If the recipient is not allowed to receive the requested hold, " .
230                  "call this method to attempt the override",
231         params => [
232             { desc => 'Authentication token',               type => 'string' },
233             {
234                 desc => 'Hold object for hold to be created',
235                 type => 'object', class => 'ahr'
236             }
237         ],
238         return => {
239             desc => 'New hold (ahr) ID on success, -1 on missing arg, event (or ref to array of events) on error(s)',
240         },
241     }
242 );
243
244 sub create_hold {
245     my( $self, $conn, $auth, $hold, $oargs ) = @_;
246     return -1 unless $hold;
247     my $e = new_editor(authtoken=>$auth, xact=>1);
248     return $e->die_event unless $e->checkauth;
249
250     my $override = 0;
251     if ($self->api_name =~ /override/) {
252         $override = 1;
253         $oargs = { all => 1 } unless defined $oargs;
254     }
255
256     my @events;
257
258     my $requestor = $e->requestor;
259     my $recipient = $requestor;
260
261     if( $requestor->id ne $hold->usr ) {
262         # Make sure the requestor is allowed to place holds for
263         # the recipient if they are not the same people
264         $recipient = $e->retrieve_actor_user($hold->usr)  or return $e->die_event;
265         $e->allowed('REQUEST_HOLDS', $recipient->home_ou) or return $e->die_event;
266     }
267
268     # If the related org setting tells us to, block if patron privs have expired
269     my $expire_setting = $U->ou_ancestor_setting_value($recipient->home_ou, OILS_SETTING_BLOCK_HOLD_FOR_EXPIRED_PATRON);
270     if ($expire_setting) {
271         my $expire = DateTime::Format::ISO8601->new->parse_datetime(
272             clean_ISO8601($recipient->expire_date));
273
274         push( @events, OpenILS::Event->new(
275             'PATRON_ACCOUNT_EXPIRED',
276             "payload" => {"fail_part" => "actor.usr.privs_expired"}
277             )) if( CORE::time > $expire->epoch ) ;
278     }
279
280     # Now make sure the recipient is allowed to receive the specified hold
281     my $porg = $recipient->home_ou;
282     my $rid  = $e->requestor->id;
283     my $t    = $hold->hold_type;
284
285     # See if a duplicate hold already exists
286     my $sargs = {
287         usr              => $recipient->id,
288         hold_type        => $t,
289         fulfillment_time => undef,
290         target           => $hold->target,
291         cancel_time      => undef,
292     };
293
294     $sargs->{holdable_formats} = $hold->holdable_formats if $t eq 'M';
295
296     my $existing = $e->search_action_hold_request($sargs);
297     if (@$existing) {
298         # See if the requestor has the CREATE_DUPLICATE_HOLDS perm.
299         my $can_dup = $e->allowed('CREATE_DUPLICATE_HOLDS', $recipient->home_ou);
300         # How many are allowed.
301         my $num_dups = $U->ou_ancestor_setting_value($recipient->home_ou, OILS_SETTING_MAX_DUPLICATE_HOLDS, $e) || 0;
302         push( @events, OpenILS::Event->new('HOLD_EXISTS'))
303             unless (($t eq 'T' || $t eq 'M') && $can_dup && scalar(@$existing) < $num_dups);
304         # Note: We check for @$existing < $num_dups because we're adding a hold with this call.
305     }
306
307     my $checked_out = hold_item_is_checked_out($e, $recipient->id, $hold->hold_type, $hold->target);
308     push( @events, OpenILS::Event->new('HOLD_ITEM_CHECKED_OUT')) if $checked_out;
309
310     if ( $t eq OILS_HOLD_TYPE_METARECORD ) {
311         return $e->die_event unless $e->allowed('MR_HOLDS',     $porg);
312     } elsif ( $t eq OILS_HOLD_TYPE_TITLE ) {
313         return $e->die_event unless $e->allowed('TITLE_HOLDS',  $porg);
314     } elsif ( $t eq OILS_HOLD_TYPE_VOLUME ) {
315         return $e->die_event unless $e->allowed('VOLUME_HOLDS', $porg);
316     } elsif ( $t eq OILS_HOLD_TYPE_MONOPART ) {
317         return $e->die_event unless $e->allowed('TITLE_HOLDS', $porg);
318     } elsif ( $t eq OILS_HOLD_TYPE_ISSUANCE ) {
319         return $e->die_event unless $e->allowed('ISSUANCE_HOLDS', $porg);
320     } elsif ( $t eq OILS_HOLD_TYPE_COPY ) {
321         return $e->die_event unless $e->allowed('COPY_HOLDS',   $porg);
322     } elsif ( $t eq OILS_HOLD_TYPE_FORCE || $t eq OILS_HOLD_TYPE_RECALL ) {
323         my $copy = $e->retrieve_asset_copy($hold->target)
324             or return $e->die_event;
325         if ( $t eq OILS_HOLD_TYPE_FORCE ) {
326             return $e->die_event unless $e->allowed('COPY_HOLDS_FORCE',   $copy->circ_lib);
327         } elsif ( $t eq OILS_HOLD_TYPE_RECALL ) {
328             return $e->die_event unless $e->allowed('COPY_HOLDS_RECALL',   $copy->circ_lib);
329         }
330     }
331
332     if( @events ) {
333         if (!$override) {
334             $e->rollback;
335             return \@events;
336         }
337         for my $evt (@events) {
338             next unless $evt;
339             my $name = $evt->{textcode};
340             if($oargs->{all} || grep { $_ eq $name } @{$oargs->{events}}) {
341                 return $e->die_event unless $e->allowed("$name.override", $porg);
342             } else {
343                 $e->rollback;
344                 return \@events;
345             }
346         }
347     }
348
349         # Check for hold expiration in the past, and set it to empty string.
350         $hold->expire_time(undef) if ($hold->expire_time && $U->datecmp($hold->expire_time) == -1);
351
352     # set the configured expire time
353     unless($hold->expire_time || $U->is_true($hold->frozen)) {
354         $hold->expire_time(calculate_expire_time($recipient->home_ou));
355     }
356
357
358     # if behind-the-desk pickup is supported at the hold pickup lib,
359     # set the value to the patron default, unless a value has already
360     # been applied.  If it's not supported, force the value to false.
361
362     my $bdous = $U->ou_ancestor_setting_value(
363         $hold->pickup_lib, 
364         'circ.holds.behind_desk_pickup_supported', $e);
365
366     if ($bdous) {
367         if (!defined $hold->behind_desk) {
368
369             my $set = $e->search_actor_user_setting({
370                 usr => $hold->usr, 
371                 name => 'circ.holds_behind_desk'
372             })->[0];
373         
374             $hold->behind_desk('t') if $set and 
375                 OpenSRF::Utils::JSON->JSON2perl($set->value);
376         }
377     } else {
378         # behind the desk not supported, force it to false
379         $hold->behind_desk('f');
380     }
381
382     $hold->requestor($e->requestor->id);
383     $hold->request_lib($e->requestor->ws_ou);
384     $hold->selection_ou($hold->pickup_lib) unless $hold->selection_ou;
385     $hold = $e->create_action_hold_request($hold) or return $e->die_event;
386
387     $e->commit;
388
389     $conn->respond_complete($hold->id);
390
391     $U->simplereq('open-ils.hold-targeter',
392         'open-ils.hold-targeter.target', {hold => $hold->id}
393     ) unless $U->is_true($hold->frozen);
394
395     return undef;
396 }
397
398 # makes sure that a user has permission to place the type of requested hold
399 # returns the Perm exception if not allowed, returns undef if all is well
400 sub _check_holds_perm {
401     my($type, $user_id, $org_id) = @_;
402
403     my $evt;
404     if ($type eq "M") {
405         $evt = $apputils->check_perms($user_id, $org_id, "MR_HOLDS"    );
406     } elsif ($type eq "T") {
407         $evt = $apputils->check_perms($user_id, $org_id, "TITLE_HOLDS" );
408     } elsif($type eq "V") {
409         $evt = $apputils->check_perms($user_id, $org_id, "VOLUME_HOLDS");
410     } elsif($type eq "C") {
411         $evt = $apputils->check_perms($user_id, $org_id, "COPY_HOLDS"  );
412     }
413
414     return $evt if $evt;
415     return undef;
416 }
417
418 # tests if the given user is allowed to place holds on another's behalf
419 sub _check_request_holds_perm {
420     my $user_id = shift;
421     my $org_id  = shift;
422     if (my $evt = $apputils->check_perms(
423         $user_id, $org_id, "REQUEST_HOLDS")) {
424         return $evt;
425     }
426 }
427
428 my $ses_is_req_note = 'The login session is the requestor.  If the requestor is different from the user, ' .
429                       'then the requestor must have VIEW_HOLD permissions';
430
431 __PACKAGE__->register_method(
432     method    => "retrieve_holds_by_id",
433     api_name  => "open-ils.circ.holds.retrieve_by_id",
434     signature => {
435         desc   => "Retrieve the hold, with hold transits attached, for the specified ID.  $ses_is_req_note",
436         params => [
437             { desc => 'Authentication token', type => 'string' },
438             { desc => 'Hold ID',              type => 'number' }
439         ],
440         return => {
441             desc => 'Hold object with transits attached, event on error',
442         }
443     }
444 );
445
446
447 sub retrieve_holds_by_id {
448     my($self, $client, $auth, $hold_id) = @_;
449     my $e = new_editor(authtoken=>$auth);
450     $e->checkauth or return $e->event;
451     $e->allowed('VIEW_HOLD') or return $e->event;
452
453     my $holds = $e->search_action_hold_request(
454         [
455             { id =>  $hold_id , fulfillment_time => undef },
456             {
457                 order_by => { ahr => "request_time" },
458                 flesh => 1,
459                 flesh_fields => {ahr => ['notes']}
460             }
461         ]
462     );
463
464     flesh_hold_transits($holds);
465     flesh_hold_notices($holds, $e);
466     return $holds;
467 }
468
469
470 __PACKAGE__->register_method(
471     method    => "retrieve_holds",
472     api_name  => "open-ils.circ.holds.retrieve",
473     signature => {
474         desc   => "Retrieves all the holds, with hold transits attached, for the specified user.  $ses_is_req_note",
475         params => [
476             { desc => 'Authentication token', type => 'string'  },
477             { desc => 'User ID',              type => 'integer' },
478             { desc => 'Available Only',       type => 'boolean' }
479         ],
480         return => {
481             desc => 'list of holds, event on error',
482         }
483    }
484 );
485
486 __PACKAGE__->register_method(
487     method        => "retrieve_holds",
488     api_name      => "open-ils.circ.holds.id_list.retrieve",
489     authoritative => 1,
490     signature     => {
491         desc   => "Retrieves all the hold IDs, for the specified user.  $ses_is_req_note",
492         params => [
493             { desc => 'Authentication token', type => 'string'  },
494             { desc => 'User ID',              type => 'integer' },
495             { desc => 'Available Only',       type => 'boolean' }
496         ],
497         return => {
498             desc => 'list of holds, event on error',
499         }
500    }
501 );
502
503 __PACKAGE__->register_method(
504     method        => "retrieve_holds",
505     api_name      => "open-ils.circ.holds.canceled.retrieve",
506     authoritative => 1,
507     signature     => {
508         desc   => "Retrieves all the cancelled holds for the specified user.  $ses_is_req_note",
509         params => [
510             { desc => 'Authentication token', type => 'string'  },
511             { desc => 'User ID',              type => 'integer' }
512         ],
513         return => {
514             desc => 'list of holds, event on error',
515         }
516    }
517 );
518
519 __PACKAGE__->register_method(
520     method        => "retrieve_holds",
521     api_name      => "open-ils.circ.holds.canceled.id_list.retrieve",
522     authoritative => 1,
523     signature     => {
524         desc   => "Retrieves list of cancelled hold IDs for the specified user.  $ses_is_req_note",
525         params => [
526             { desc => 'Authentication token', type => 'string'  },
527             { desc => 'User ID',              type => 'integer' }
528         ],
529         return => {
530             desc => 'list of hold IDs, event on error',
531         }
532    }
533 );
534
535
536 sub retrieve_holds {
537     my ($self, $client, $auth, $user_id, $available) = @_;
538
539     my $e = new_editor(authtoken=>$auth);
540     return $e->event unless $e->checkauth;
541     $user_id = $e->requestor->id unless defined $user_id;
542
543     my $notes_filter = {staff => 'f'};
544     my $user = $e->retrieve_actor_user($user_id) or return $e->event;
545     unless($user_id == $e->requestor->id) {
546         if($e->allowed('VIEW_HOLD', $user->home_ou)) {
547             $notes_filter = {staff => 't'}
548         } else {
549             my $allowed = OpenILS::Application::Actor::Friends->friend_perm_allowed(
550                 $e, $user_id, $e->requestor->id, 'hold.view');
551             return $e->event unless $allowed;
552         }
553     } else {
554         # staff member looking at his/her own holds can see staff and non-staff notes
555         $notes_filter = {} if $e->allowed('VIEW_HOLD', $user->home_ou);
556     }
557
558     my $holds_query = {
559         select => {ahr => ['id']},
560         from => 'ahr',
561         where => {usr => $user_id, fulfillment_time => undef}
562     };
563
564     if($self->api_name =~ /canceled/) {
565
566         # Fetch the canceled holds
567         # order cancelled holds by cancel time, most recent first
568
569         $holds_query->{order_by} = [{class => 'ahr', field => 'cancel_time', direction => 'desc'}];
570
571         my $cancel_age;
572         my $cancel_count = $U->ou_ancestor_setting_value(
573                 $e->requestor->ws_ou, 'circ.holds.canceled.display_count', $e);
574
575         unless($cancel_count) {
576             $cancel_age = $U->ou_ancestor_setting_value(
577                 $e->requestor->ws_ou, 'circ.holds.canceled.display_age', $e);
578
579             # if no settings are defined, default to last 10 cancelled holds
580             $cancel_count = 10 unless $cancel_age;
581         }
582
583         if($cancel_count) { # limit by count
584
585             $holds_query->{where}->{cancel_time} = {'!=' => undef};
586             $holds_query->{limit} = $cancel_count;
587
588         } elsif($cancel_age) { # limit by age
589
590             # find all of the canceled holds that were canceled within the configured time frame
591             my $date = DateTime->now->subtract(seconds => OpenILS::Utils::DateTime->interval_to_seconds($cancel_age));
592             $date = $U->epoch2ISO8601($date->epoch);
593             $holds_query->{where}->{cancel_time} = {'>=' => $date};
594         }
595
596     } else {
597
598         # order non-cancelled holds by ready-for-pickup, then active, followed by suspended
599         # "compare" sorts false values to the front.  testing pickup_lib != current_shelf_lib
600         # will sort by pl = csl > pl != csl > followed by csl is null;
601         $holds_query->{order_by} = [
602             {   class => 'ahr',
603                 field => 'pickup_lib',
604                 compare => {'!='  => {'+ahr' => 'current_shelf_lib'}}},
605             {class => 'ahr', field => 'shelf_time'},
606             {class => 'ahr', field => 'frozen'},
607             {class => 'ahr', field => 'request_time'}
608
609         ];
610         $holds_query->{where}->{cancel_time} = undef;
611         if($available) {
612             $holds_query->{where}->{shelf_time} = {'!=' => undef};
613             # Maybe?
614             $holds_query->{where}->{pickup_lib} = {'=' => {'+ahr' => 'current_shelf_lib'}};
615         }
616     }
617
618     my $hold_ids = $e->json_query($holds_query);
619     $hold_ids = [ map { $_->{id} } @$hold_ids ];
620
621     return $hold_ids if $self->api_name =~ /id_list/;
622
623     my @holds;
624     for my $hold_id ( @$hold_ids ) {
625
626         my $hold = $e->retrieve_action_hold_request($hold_id);
627         $hold->notes($e->search_action_hold_request_note({hold => $hold_id, %$notes_filter}));
628
629         $hold->transit(
630             $e->search_action_hold_transit_copy([
631                 {hold => $hold->id, cancel_time => undef},
632                 {order_by => {ahtc => 'source_send_time desc'}, limit => 1}])->[0]
633         );
634
635         push(@holds, $hold);
636     }
637
638     return \@holds;
639 }
640
641
642 __PACKAGE__->register_method(
643     method   => 'user_hold_count',
644     api_name => 'open-ils.circ.hold.user.count'
645 );
646
647 sub user_hold_count {
648     my ( $self, $conn, $auth, $userid ) = @_;
649     my $e = new_editor( authtoken => $auth );
650     return $e->event unless $e->checkauth;
651     my $patron = $e->retrieve_actor_user($userid)
652         or return $e->event;
653     return $e->event unless $e->allowed( 'VIEW_HOLD', $patron->home_ou );
654     return __user_hold_count( $self, $e, $userid );
655 }
656
657 sub __user_hold_count {
658     my ( $self, $e, $userid ) = @_;
659     my $holds = $e->search_action_hold_request(
660         {
661             usr              => $userid,
662             fulfillment_time => undef,
663             cancel_time      => undef,
664         },
665         { idlist => 1 }
666     );
667
668     return scalar(@$holds);
669 }
670
671
672 __PACKAGE__->register_method(
673     method   => "retrieve_holds_by_pickup_lib",
674     api_name => "open-ils.circ.holds.retrieve_by_pickup_lib",
675     notes    =>
676       "Retrieves all the holds, with hold transits attached, for the specified pickup_ou id."
677 );
678
679 __PACKAGE__->register_method(
680     method   => "retrieve_holds_by_pickup_lib",
681     api_name => "open-ils.circ.holds.id_list.retrieve_by_pickup_lib",
682     notes    => "Retrieves all the hold ids for the specified pickup_ou id. "
683 );
684
685 sub retrieve_holds_by_pickup_lib {
686     my ($self, $client, $login_session, $ou_id) = @_;
687
688     #FIXME -- put an appropriate permission check here
689     #my( $user, $target, $evt ) = $apputils->checkses_requestor(
690     #    $login_session, $user_id, 'VIEW_HOLD' );
691     #return $evt if $evt;
692
693     my $holds = $apputils->simplereq(
694         'open-ils.cstore',
695         "open-ils.cstore.direct.action.hold_request.search.atomic",
696         {
697             pickup_lib =>  $ou_id ,
698             fulfillment_time => undef,
699             cancel_time => undef
700         },
701         { order_by => { ahr => "request_time" } }
702     );
703
704     if ( ! $self->api_name =~ /id_list/ ) {
705         flesh_hold_transits($holds);
706         return $holds;
707     }
708     # else id_list
709     return [ map { $_->id } @$holds ];
710 }
711
712
713 __PACKAGE__->register_method(
714     method   => "uncancel_hold",
715     api_name => "open-ils.circ.hold.uncancel"
716 );
717
718 sub uncancel_hold {
719     my($self, $client, $auth, $hold_id) = @_;
720     my $e = new_editor(authtoken=>$auth, xact=>1);
721     return $e->die_event unless $e->checkauth;
722
723     my $hold = $e->retrieve_action_hold_request($hold_id)
724         or return $e->die_event;
725     return $e->die_event unless $e->allowed('CANCEL_HOLDS', $hold->request_lib);
726
727     if ($hold->fulfillment_time) {
728         $e->rollback;
729         return 0;
730     }
731     unless ($hold->cancel_time) {
732         $e->rollback;
733         return 1;
734     }
735
736     # if configured to reset the request time, also reset the expire time
737     if($U->ou_ancestor_setting_value(
738         $hold->request_lib, 'circ.holds.uncancel.reset_request_time', $e)) {
739
740         $hold->request_time('now');
741         $hold->expire_time(calculate_expire_time($hold->request_lib));
742     }
743
744     $hold->clear_cancel_time;
745     $hold->clear_cancel_cause;
746     $hold->clear_cancel_note;
747     $hold->clear_shelf_time;
748     $hold->clear_current_copy;
749     $hold->clear_capture_time;
750     $hold->clear_prev_check_time;
751     $hold->clear_shelf_expire_time;
752     $hold->clear_current_shelf_lib;
753
754     $e->update_action_hold_request($hold) or return $e->die_event;
755     $e->commit;
756
757     $U->simplereq('open-ils.hold-targeter',
758         'open-ils.hold-targeter.target', {hold => $hold_id});
759
760     return 1;
761 }
762
763
764 __PACKAGE__->register_method(
765     method    => "cancel_hold",
766     api_name  => "open-ils.circ.hold.cancel",
767     signature => {
768         desc   => 'Cancels the specified hold.  The login session is the requestor.  If the requestor is different from the usr field ' .
769                   'on the hold, the requestor must have CANCEL_HOLDS permissions. The hold may be either the hold object or the hold id',
770         param  => [
771             {desc => 'Authentication token',  type => 'string'},
772             {desc => 'Hold ID',               type => 'number'},
773             {desc => 'Cause of Cancellation', type => 'string'},
774             {desc => 'Note',                  type => 'string'}
775         ],
776         return => {
777             desc => '1 on success, event on error'
778         }
779     }
780 );
781
782 sub cancel_hold {
783     my($self, $client, $auth, $holdid, $cause, $note) = @_;
784
785     my $e = new_editor(authtoken=>$auth, xact=>1);
786     return $e->die_event unless $e->checkauth;
787
788     my $hold = $e->retrieve_action_hold_request($holdid)
789         or return $e->die_event;
790
791     if( $e->requestor->id ne $hold->usr ) {
792         return $e->die_event unless $e->allowed('CANCEL_HOLDS');
793     }
794
795     if ($hold->cancel_time) {
796         $e->rollback;
797         return 1;
798     }
799
800     # If the hold is captured, reset the copy status
801     if( $hold->capture_time and $hold->current_copy ) {
802
803         my $copy = $e->retrieve_asset_copy($hold->current_copy)
804             or return $e->die_event;
805
806         if( $copy->status == OILS_COPY_STATUS_ON_HOLDS_SHELF ) {
807          $logger->info("canceling hold $holdid whose item is on the holds shelf");
808 #            $logger->info("setting copy to status 'reshelving' on hold cancel");
809 #            $copy->status(OILS_COPY_STATUS_RESHELVING);
810 #            $copy->editor($e->requestor->id);
811 #            $copy->edit_date('now');
812 #            $e->update_asset_copy($copy) or return $e->event;
813
814         } elsif( $copy->status == OILS_COPY_STATUS_IN_TRANSIT ) {
815
816             my $hid = $hold->id;
817             $logger->warn("! canceling hold [$hid] that is in transit");
818             my $transid = $e->search_action_hold_transit_copy({hold=>$hold->id,cancel_time=>undef},{idlist=>1})->[0];
819
820             if( $transid ) {
821                 my $trans = $e->retrieve_action_transit_copy($transid);
822                 # Leave the transit alive, but  set the copy status to
823                 # reshelving so it will be properly reshelved when it gets back home
824                 if( $trans ) {
825                     $trans->copy_status( OILS_COPY_STATUS_RESHELVING );
826                     $e->update_action_transit_copy($trans) or return $e->die_event;
827                 }
828             }
829         }
830     }
831
832     $hold->cancel_time('now');
833     $hold->cancel_cause($cause);
834     $hold->cancel_note($note);
835     $e->update_action_hold_request($hold)
836         or return $e->die_event;
837
838     $e->commit;
839
840     # re-fetch the hold to pick up the real cancel_time (not "now") for A/T
841     $e->xact_begin;
842     $hold = $e->retrieve_action_hold_request($hold->id) or return $e->die_event;
843     $e->rollback;
844
845     if ($e->requestor->id == $hold->usr) {
846         $U->create_events_for_hook('hold_request.cancel.patron', $hold, $hold->pickup_lib);
847     } else {
848         $U->create_events_for_hook('hold_request.cancel.staff', $hold, $hold->pickup_lib);
849     }
850
851     return 1;
852 }
853
854 my $update_hold_desc = 'The login session is the requestor. '       .
855    'If the requestor is different from the usr field on the hold, ' .
856    'the requestor must have UPDATE_HOLDS permissions. '             .
857    'If supplying a hash of hold data, "id" must be included. '      .
858    'The hash is ignored if a hold object is supplied, '             .
859    'so you should supply only one kind of hold data argument.'      ;
860
861 __PACKAGE__->register_method(
862     method    => "update_hold",
863     api_name  => "open-ils.circ.hold.update",
864     signature => {
865         desc   => "Updates the specified hold.  $update_hold_desc",
866         params => [
867             {desc => 'Authentication token',         type => 'string'},
868             {desc => 'Hold Object',                  type => 'object'},
869             {desc => 'Hash of values to be applied', type => 'object'}
870         ],
871         return => {
872             desc => 'Hold ID on success, event on error',
873             # type => 'number'
874         }
875     }
876 );
877
878 __PACKAGE__->register_method(
879     method    => "batch_update_hold",
880     api_name  => "open-ils.circ.hold.update.batch",
881     stream    => 1,
882     signature => {
883         desc   => "Updates the specified hold(s).  $update_hold_desc",
884         params => [
885             {desc => 'Authentication token',                    type => 'string'},
886             {desc => 'Array of hold obejcts',                   type => 'array' },
887             {desc => 'Array of hashes of values to be applied', type => 'array' }
888         ],
889         return => {
890             desc => 'Hold ID per success, event per error',
891         }
892     }
893 );
894
895 sub update_hold {
896     my($self, $client, $auth, $hold, $values) = @_;
897     my $e = new_editor(authtoken=>$auth, xact=>1);
898     return $e->die_event unless $e->checkauth;
899     my $resp = update_hold_impl($self, $e, $hold, $values);
900     if ($U->event_code($resp)) {
901         $e->rollback;
902         return $resp;
903     }
904     $e->commit;     # FIXME: update_hold_impl already does $e->commit  ??
905     return $resp;
906 }
907
908 sub batch_update_hold {
909     my($self, $client, $auth, $hold_list, $values_list) = @_;
910     my $e = new_editor(authtoken=>$auth);
911     return $e->die_event unless $e->checkauth;
912
913     my $count = ($hold_list) ? scalar(@$hold_list) : scalar(@$values_list);     # FIXME: we don't know for sure that we got $values_list.  we could have neither list.
914     $hold_list   ||= [];
915     $values_list ||= [];      # FIXME: either move this above $count declaration, or send an event if both lists undef.  Probably the latter.
916
917 # FIXME: Failing over to [] guarantees warnings for "Use of unitialized value" in update_hold_impl call.
918 # FIXME: We should be sure we only call update_hold_impl with hold object OR hash, not both.
919
920     for my $idx (0..$count-1) {
921         $e->xact_begin;
922         my $resp = update_hold_impl($self, $e, $hold_list->[$idx], $values_list->[$idx]);
923         $e->xact_commit unless $U->event_code($resp);
924         $client->respond($resp);
925     }
926
927     $e->disconnect;
928     return undef;       # not in the register return type, assuming we should always have at least one list populated
929 }
930
931 sub update_hold_impl {
932     my($self, $e, $hold, $values) = @_;
933     my $hold_status;
934     my $need_retarget = 0;
935
936     unless($hold) {
937         $hold = $e->retrieve_action_hold_request($values->{id})
938             or return $e->die_event;
939         for my $k (keys %$values) {
940             # Outside of pickup_lib (covered by the first regex) I don't know when these would currently change.
941             # But hey, why not cover things that may happen later?
942             if ($k =~ '_(lib|ou)$' || $k eq 'target' || $k eq 'hold_type' || $k eq 'requestor' || $k eq 'selection_depth' || $k eq 'holdable_formats') {
943                 if (defined $values->{$k} && defined $hold->$k() && $values->{$k} ne $hold->$k()) {
944                     # Value changed? RETARGET!
945                     $need_retarget = 1;
946                 } elsif (defined $hold->$k() != defined $values->{$k}) {
947                     # Value being set or cleared? RETARGET!
948                     $need_retarget = 1;
949                 }
950             }
951             if (defined $values->{$k}) {
952                 $hold->$k($values->{$k});
953             } else {
954                 my $f = "clear_$k"; $hold->$f();
955             }
956         }
957     }
958
959     my $orig_hold = $e->retrieve_action_hold_request($hold->id)
960         or return $e->die_event;
961
962     # don't allow the user to be changed
963     return OpenILS::Event->new('BAD_PARAMS') if $hold->usr != $orig_hold->usr;
964
965     if($hold->usr ne $e->requestor->id) {
966         # if the hold is for a different user, make sure the
967         # requestor has the appropriate permissions
968         my $usr = $e->retrieve_actor_user($hold->usr)
969             or return $e->die_event;
970         return $e->die_event unless $e->allowed('UPDATE_HOLD', $usr->home_ou);
971     }
972
973
974     # --------------------------------------------------------------
975     # Changing the request time is like playing God
976     # --------------------------------------------------------------
977     if($hold->request_time ne $orig_hold->request_time) {
978         return OpenILS::Event->new('BAD_PARAMS') if $hold->fulfillment_time;
979         return $e->die_event unless $e->allowed('UPDATE_HOLD_REQUEST_TIME', $hold->pickup_lib);
980     }
981
982
983     # --------------------------------------------------------------
984     # Code for making sure staff have appropriate permissons for cut_in_line
985     # This, as is, doesn't prevent a user from cutting their own holds in line
986     # but needs to
987     # --------------------------------------------------------------
988     if($U->is_true($hold->cut_in_line) ne $U->is_true($orig_hold->cut_in_line)) {
989         return $e->die_event unless $e->allowed('UPDATE_HOLD_REQUEST_TIME', $hold->pickup_lib);
990     }
991
992
993     # --------------------------------------------------------------
994     # Disallow hold suspencion if the hold is already captured.
995     # --------------------------------------------------------------
996     if ($U->is_true($hold->frozen) and not $U->is_true($orig_hold->frozen)) {
997         $hold_status = _hold_status($e, $hold);
998         if ($hold_status > 2 && $hold_status != 7) { # hold is captured
999             $logger->info("bypassing hold freeze on captured hold");
1000             return OpenILS::Event->new('HOLD_SUSPEND_AFTER_CAPTURE');
1001         }
1002     }
1003
1004
1005     # --------------------------------------------------------------
1006     # if the hold is on the holds shelf or in transit and the pickup
1007     # lib changes we need to create a new transit.
1008     # --------------------------------------------------------------
1009     if($orig_hold->pickup_lib ne $hold->pickup_lib) {
1010
1011         $hold_status = _hold_status($e, $hold) unless $hold_status;
1012
1013         if($hold_status == 3) { # in transit
1014
1015             return $e->die_event unless $e->allowed('UPDATE_PICKUP_LIB_FROM_TRANSIT', $orig_hold->pickup_lib);
1016             return $e->die_event unless $e->allowed('UPDATE_PICKUP_LIB_FROM_TRANSIT', $hold->pickup_lib);
1017
1018             $logger->info("updating pickup lib for hold ".$hold->id." while already in transit");
1019
1020             # update the transit to reflect the new pickup location
1021             my $transit = $e->search_action_hold_transit_copy(
1022                 {hold=>$hold->id, cancel_time => undef, dest_recv_time => undef})->[0]
1023                 or return $e->die_event;
1024
1025             $transit->prev_dest($transit->dest); # mark the previous destination on the transit
1026             $transit->dest($hold->pickup_lib);
1027             $e->update_action_hold_transit_copy($transit) or return $e->die_event;
1028
1029         } elsif($hold_status == 4 or $hold_status == 5 or $hold_status == 8) { # on holds shelf
1030
1031             return $e->die_event unless $e->allowed('UPDATE_PICKUP_LIB_FROM_HOLDS_SHELF', $orig_hold->pickup_lib);
1032             return $e->die_event unless $e->allowed('UPDATE_PICKUP_LIB_FROM_HOLDS_SHELF', $hold->pickup_lib);
1033
1034             $logger->info("updating pickup lib for hold ".$hold->id." while on holds shelf");
1035
1036             if ($hold->pickup_lib eq $orig_hold->current_shelf_lib) {
1037                 # This can happen if the pickup lib is changed while the hold is
1038                 # on the shelf, then changed back to the original pickup lib.
1039                 # Restore the original shelf_expire_time to prevent abuse.
1040                 set_hold_shelf_expire_time(undef, $hold, $e, $hold->shelf_time);
1041
1042             } else {
1043                 # clear to prevent premature shelf expiration
1044                 $hold->clear_shelf_expire_time;
1045             }
1046         }
1047     }
1048
1049     if($U->is_true($hold->frozen)) {
1050         $logger->info("clearing current_copy and check_time for frozen hold ".$hold->id);
1051         $hold->clear_current_copy;
1052         $hold->clear_prev_check_time;
1053         # Clear expire_time to prevent frozen holds from expiring.
1054         $logger->info("clearing expire_time for frozen hold ".$hold->id);
1055         $hold->clear_expire_time;
1056     }
1057
1058     # If the hold_expire_time is in the past && is not equal to the
1059     # original expire_time, then reset the expire time to be in the
1060     # future.
1061     if ($hold->expire_time && $U->datecmp($hold->expire_time) == -1 && $U->datecmp($hold->expire_time, $orig_hold->expire_time) != 0) {
1062         $hold->expire_time(calculate_expire_time($hold->request_lib));
1063     }
1064
1065     # If the hold is reactivated, reset the expire_time.
1066     if(!$U->is_true($hold->frozen) && $U->is_true($orig_hold->frozen)) {
1067         $logger->info("Reset expire_time on activated hold ".$hold->id);
1068         $hold->expire_time(calculate_expire_time($hold->request_lib));
1069     }
1070
1071     $e->update_action_hold_request($hold) or return $e->die_event;
1072     $e->commit;
1073
1074     if(!$U->is_true($hold->frozen) && $U->is_true($orig_hold->frozen)) {
1075         $logger->info("Running targeter on activated hold ".$hold->id);
1076         $U->simplereq('open-ils.hold-targeter', 
1077             'open-ils.hold-targeter.target', {hold => $hold->id});
1078     }
1079
1080     # a change to mint-condition changes the set of potential copies, so retarget the hold;
1081     if($U->is_true($hold->mint_condition) and !$U->is_true($orig_hold->mint_condition)) {
1082         _reset_hold($self, $e->requestor, $hold)
1083     } elsif($need_retarget && !defined $hold->capture_time()) { # If needed, retarget the hold due to changes
1084         $U->simplereq('open-ils.hold-targeter', 
1085             'open-ils.hold-targeter.target', {hold => $hold->id});
1086     }
1087
1088     return $hold->id;
1089 }
1090
1091 # this does not update the hold in the DB.  It only
1092 # sets the shelf_expire_time field on the hold object.
1093 # start_time is optional and defaults to 'now'
1094 sub set_hold_shelf_expire_time {
1095     my ($class, $hold, $editor, $start_time) = @_;
1096
1097     my $shelf_expire = $U->ou_ancestor_setting_value(
1098         $hold->pickup_lib,
1099         'circ.holds.default_shelf_expire_interval',
1100         $editor
1101     );
1102
1103     return undef unless $shelf_expire;
1104
1105     $start_time = ($start_time) ?
1106         DateTime::Format::ISO8601->new->parse_datetime(clean_ISO8601($start_time)) :
1107         DateTime->now(time_zone => 'local'); # without time_zone we get UTC ... yuck!
1108
1109     my $seconds = OpenILS::Utils::DateTime->interval_to_seconds($shelf_expire);
1110     my $expire_time = $start_time->add(seconds => $seconds);
1111
1112     # if the shelf expire time overlaps with a pickup lib's
1113     # closed date, push it out to the first open date
1114     my $dateinfo = $U->storagereq(
1115         'open-ils.storage.actor.org_unit.closed_date.overlap',
1116         $hold->pickup_lib, $expire_time->strftime('%FT%T%z'));
1117
1118     if($dateinfo) {
1119         my $dt_parser = DateTime::Format::ISO8601->new;
1120         $expire_time = $dt_parser->parse_datetime(clean_ISO8601($dateinfo->{end}));
1121
1122         # TODO: enable/disable time bump via setting?
1123         $expire_time->set(hour => '23', minute => '59', second => '59');
1124
1125         $logger->info("circulator: shelf_expire_time overlaps".
1126             " with closed date, pushing expire time to $expire_time");
1127     }
1128
1129     $hold->shelf_expire_time($expire_time->strftime('%FT%T%z'));
1130     return undef;
1131 }
1132
1133
1134 sub transit_hold {
1135     my($e, $orig_hold, $hold, $copy) = @_;
1136     my $src  = $orig_hold->pickup_lib;
1137     my $dest = $hold->pickup_lib;
1138
1139     $logger->info("putting hold into transit on pickup_lib update");
1140
1141     my $transit = Fieldmapper::action::hold_transit_copy->new;
1142     $transit->hold($hold->id);
1143     $transit->source($src);
1144     $transit->dest($dest);
1145     $transit->target_copy($copy->id);
1146     $transit->source_send_time('now');
1147     $transit->copy_status(OILS_COPY_STATUS_ON_HOLDS_SHELF);
1148
1149     $copy->status(OILS_COPY_STATUS_IN_TRANSIT);
1150     $copy->editor($e->requestor->id);
1151     $copy->edit_date('now');
1152
1153     $e->create_action_hold_transit_copy($transit) or return $e->die_event;
1154     $e->update_asset_copy($copy) or return $e->die_event;
1155     return undef;
1156 }
1157
1158 # if the hold is frozen, this method ensures that the hold is not "targeted",
1159 # that is, it clears the current_copy and prev_check_time to essentiallly
1160 # reset the hold.  If it is being activated, it runs the targeter in the background
1161 sub update_hold_if_frozen {
1162     my($self, $e, $hold, $orig_hold) = @_;
1163     return if $hold->capture_time;
1164
1165     if($U->is_true($hold->frozen)) {
1166         $logger->info("clearing current_copy and check_time for frozen hold ".$hold->id);
1167         $hold->clear_current_copy;
1168         $hold->clear_prev_check_time;
1169
1170     } else {
1171         if($U->is_true($orig_hold->frozen)) {
1172             $logger->info("Running targeter on activated hold ".$hold->id);
1173             $U->simplereq('open-ils.hold-targeter', 
1174                 'open-ils.hold-targeter.target', {hold => $hold->id});
1175         }
1176     }
1177 }
1178
1179 __PACKAGE__->register_method(
1180     method    => "hold_note_CUD",
1181     api_name  => "open-ils.circ.hold_request.note.cud",
1182     signature => {
1183         desc   => 'Create, update or delete a hold request note.  If the operator (from Auth. token) '
1184                 . 'is not the owner of the hold, the UPDATE_HOLD permission is required',
1185         params => [
1186             { desc => 'Authentication token', type => 'string' },
1187             { desc => 'Hold note object',     type => 'object' }
1188         ],
1189         return => {
1190             desc => 'Returns the note ID, event on error'
1191         },
1192     }
1193 );
1194
1195 sub hold_note_CUD {
1196     my($self, $conn, $auth, $note) = @_;
1197
1198     my $e = new_editor(authtoken => $auth, xact => 1);
1199     return $e->die_event unless $e->checkauth;
1200
1201     my $hold = $e->retrieve_action_hold_request($note->hold)
1202         or return $e->die_event;
1203
1204     if($hold->usr ne $e->requestor->id) {
1205         my $usr = $e->retrieve_actor_user($hold->usr);
1206         return $e->die_event unless $e->allowed('UPDATE_HOLD', $usr->home_ou);
1207         $note->staff('t') if $note->isnew;
1208     }
1209
1210     if($note->isnew) {
1211         $e->create_action_hold_request_note($note) or return $e->die_event;
1212     } elsif($note->ischanged) {
1213         $e->update_action_hold_request_note($note) or return $e->die_event;
1214     } elsif($note->isdeleted) {
1215         $e->delete_action_hold_request_note($note) or return $e->die_event;
1216     }
1217
1218     $e->commit;
1219     return $note->id;
1220 }
1221
1222
1223 __PACKAGE__->register_method(
1224     method    => "retrieve_hold_status",
1225     api_name  => "open-ils.circ.hold.status.retrieve",
1226     signature => {
1227         desc   => 'Calculates the current status of the hold. The requestor must have '      .
1228                   'VIEW_HOLD permissions if the hold is for a user other than the requestor' ,
1229         param  => [
1230             { desc => 'Hold ID', type => 'number' }
1231         ],
1232         return => {
1233             # type => 'number',     # event sometimes
1234             desc => <<'END_OF_DESC'
1235 Returns event on error or:
1236 -1 on error (for now),
1237  1 for 'waiting for copy to become available',
1238  2 for 'waiting for copy capture',
1239  3 for 'in transit',
1240  4 for 'arrived',
1241  5 for 'hold-shelf-delay'
1242  6 for 'canceled'
1243  7 for 'suspended'
1244  8 for 'captured, on wrong hold shelf'
1245  9 for 'fulfilled'
1246 END_OF_DESC
1247         }
1248     }
1249 );
1250
1251 sub retrieve_hold_status {
1252     my($self, $client, $auth, $hold_id) = @_;
1253
1254     my $e = new_editor(authtoken => $auth);
1255     return $e->event unless $e->checkauth;
1256     my $hold = $e->retrieve_action_hold_request($hold_id)
1257         or return $e->event;
1258
1259     if( $e->requestor->id != $hold->usr ) {
1260         return $e->event unless $e->allowed('VIEW_HOLD');
1261     }
1262
1263     return _hold_status($e, $hold);
1264
1265 }
1266
1267 sub _hold_status {
1268     my($e, $hold) = @_;
1269     if ($hold->cancel_time) {
1270         return 6;
1271     }
1272     if ($U->is_true($hold->frozen) && !$hold->capture_time) {
1273         return 7;
1274     }
1275     if ($hold->current_shelf_lib and $hold->current_shelf_lib ne $hold->pickup_lib) {
1276         return 8;
1277     }
1278     if ($hold->fulfillment_time) {
1279         return 9;
1280     }
1281     return 1 unless $hold->current_copy;
1282     return 2 unless $hold->capture_time;
1283
1284     my $copy = $hold->current_copy;
1285     unless( ref $copy ) {
1286         $copy = $e->retrieve_asset_copy($hold->current_copy)
1287             or return $e->event;
1288     }
1289
1290     return 3 if $copy->status == OILS_COPY_STATUS_IN_TRANSIT;
1291
1292     if($copy->status == OILS_COPY_STATUS_ON_HOLDS_SHELF) {
1293
1294         my $hs_wait_interval = $U->ou_ancestor_setting_value($hold->pickup_lib, 'circ.hold_shelf_status_delay');
1295         return 4 unless $hs_wait_interval;
1296
1297         # if a hold_shelf_status_delay interval is defined and start_time plus
1298         # the interval is greater than now, consider the hold to be in the virtual
1299         # "on its way to the holds shelf" status. Return 5.
1300
1301         my $transit    = $e->search_action_hold_transit_copy({
1302                             hold           => $hold->id,
1303                             target_copy    => $copy->id,
1304                             cancel_time     => undef,
1305                             dest_recv_time => {'!=' => undef},
1306                          })->[0];
1307         my $start_time = ($transit) ? $transit->dest_recv_time : $hold->capture_time;
1308         $start_time    = DateTime::Format::ISO8601->new->parse_datetime(clean_ISO8601($start_time));
1309         my $end_time   = $start_time->add(seconds => OpenILS::Utils::DateTime->interval_to_seconds($hs_wait_interval));
1310
1311         return 5 if $end_time > DateTime->now;
1312         return 4;
1313     }
1314
1315     return -1;  # error
1316 }
1317
1318
1319
1320 __PACKAGE__->register_method(
1321     method    => "retrieve_hold_queue_stats",
1322     api_name  => "open-ils.circ.hold.queue_stats.retrieve",
1323     signature => {
1324         desc   => 'Returns summary data about the state of a hold',
1325         params => [
1326             { desc => 'Authentication token',  type => 'string'},
1327             { desc => 'Hold ID', type => 'number'},
1328         ],
1329         return => {
1330             desc => q/Summary object with keys:
1331                 total_holds : total holds in queue
1332                 queue_position : current queue position
1333                 potential_copies : number of potential copies for this hold
1334                 estimated_wait : estimated wait time in days
1335                 status : hold status
1336                      -1 => error or unexpected state,
1337                      1 => 'waiting for copy to become available',
1338                      2 => 'waiting for copy capture',
1339                      3 => 'in transit',
1340                      4 => 'arrived',
1341                      5 => 'hold-shelf-delay'
1342             /,
1343             type => 'object'
1344         }
1345     }
1346 );
1347
1348 sub retrieve_hold_queue_stats {
1349     my($self, $conn, $auth, $hold_id) = @_;
1350     my $e = new_editor(authtoken => $auth);
1351     return $e->event unless $e->checkauth;
1352     my $hold = $e->retrieve_action_hold_request($hold_id) or return $e->event;
1353     if($e->requestor->id != $hold->usr) {
1354         return $e->event unless $e->allowed('VIEW_HOLD');
1355     }
1356     return retrieve_hold_queue_status_impl($e, $hold);
1357 }
1358
1359 sub retrieve_hold_queue_status_impl {
1360     my $e = shift;
1361     my $hold = shift;
1362
1363     # The holds queue is defined as the distinct set of holds that share at
1364     # least one potential copy with the context hold, plus any holds that
1365     # share the same hold type and target.  The latter part exists to
1366     # accomodate holds that currently have no potential copies
1367     my $q_holds = $e->json_query({
1368
1369         # fetch cut_in_line and request_time since they're in the order_by
1370         # and we're asking for distinct values
1371         select => {ahr => ['id', 'cut_in_line', 'request_time']},
1372         from   => 'ahr',
1373         where => {
1374             id => { in => {
1375                 select => { ahcm => ['hold'] },
1376                 from   => {
1377                     'ahcm' => {
1378                         'ahcm2' => {
1379                             'class' => 'ahcm',
1380                             'field' => 'target_copy',
1381                             'fkey'  => 'target_copy'
1382                         }
1383                     }
1384                 },
1385                 where => { '+ahcm2' => { hold => $hold->id } },
1386                 distinct => 1
1387             }}
1388         },
1389         order_by => [
1390             {
1391                 "class" => "ahr",
1392                 "field" => "cut_in_line",
1393                 "transform" => "coalesce",
1394                 "params" => [ 0 ],
1395                 "direction" => "desc"
1396             },
1397             { "class" => "ahr", "field" => "request_time" }
1398         ],
1399         distinct => 1
1400     });
1401
1402     if (!@$q_holds) { # none? maybe we don't have a map ...
1403         $q_holds = $e->json_query({
1404             select => {ahr => ['id', 'cut_in_line', 'request_time']},
1405             from   => 'ahr',
1406             order_by => [
1407                 {
1408                     "class" => "ahr",
1409                     "field" => "cut_in_line",
1410                     "transform" => "coalesce",
1411                     "params" => [ 0 ],
1412                     "direction" => "desc"
1413                 },
1414                 { "class" => "ahr", "field" => "request_time" }
1415             ],
1416             where    => {
1417                 hold_type => $hold->hold_type,
1418                 target    => $hold->target,
1419                 capture_time => undef,
1420                 cancel_time => undef,
1421                 '-or' => [
1422                     {expire_time => undef },
1423                     {expire_time => {'>' => 'now'}}
1424                 ]
1425            }
1426         });
1427     }
1428
1429
1430     my $qpos = 1;
1431     for my $h (@$q_holds) {
1432         last if $h->{id} == $hold->id;
1433         $qpos++;
1434     }
1435
1436     my $hold_data = $e->json_query({
1437         select => {
1438             acp => [ {column => 'id', transform => 'count', aggregate => 1, alias => 'count'} ],
1439             ccm => [ {column =>'avg_wait_time'} ]
1440         },
1441         from => {
1442             ahcm => {
1443                 acp => {
1444                     join => {
1445                         ccm => {type => 'left'}
1446                     }
1447                 }
1448             }
1449         },
1450         where => {'+ahcm' => {hold => $hold->id} }
1451     });
1452
1453     my $user_org = $e->json_query({select => {au => ['home_ou']}, from => 'au', where => {id => $hold->usr}})->[0]->{home_ou};
1454
1455     my $default_wait = $U->ou_ancestor_setting_value(
1456         $user_org, OILS_SETTING_HOLD_ESIMATE_WAIT_INTERVAL, $e);
1457     my $min_wait = $U->ou_ancestor_setting_value(
1458         $user_org, 'circ.holds.min_estimated_wait_interval', $e);
1459     $min_wait = OpenILS::Utils::DateTime->interval_to_seconds($min_wait || '0 seconds');
1460     $default_wait ||= '0 seconds';
1461
1462     # Estimated wait time is the average wait time across the set
1463     # of potential copies, divided by the number of potential copies
1464     # times the queue position.
1465
1466     my $combined_secs = 0;
1467     my $num_potentials = 0;
1468
1469     for my $wait_data (@$hold_data) {
1470         my $count += $wait_data->{count};
1471         $combined_secs += $count *
1472             OpenILS::Utils::DateTime->interval_to_seconds($wait_data->{avg_wait_time} || $default_wait);
1473         $num_potentials += $count;
1474     }
1475
1476     my $estimated_wait = -1;
1477
1478     if($num_potentials) {
1479         my $avg_wait = $combined_secs / $num_potentials;
1480         $estimated_wait = $qpos * ($avg_wait / $num_potentials);
1481         $estimated_wait = $min_wait if $estimated_wait < $min_wait and $estimated_wait != -1;
1482     }
1483
1484     return {
1485         total_holds      => scalar(@$q_holds),
1486         queue_position   => $qpos,
1487         potential_copies => $num_potentials,
1488         status           => _hold_status( $e, $hold ),
1489         estimated_wait   => int($estimated_wait)
1490     };
1491 }
1492
1493
1494 sub fetch_open_hold_by_current_copy {
1495     my $class = shift;
1496     my $copyid = shift;
1497     my $hold = $apputils->simplereq(
1498         'open-ils.cstore',
1499         'open-ils.cstore.direct.action.hold_request.search.atomic',
1500         { current_copy =>  $copyid , cancel_time => undef, fulfillment_time => undef });
1501     return $hold->[0] if ref($hold);
1502     return undef;
1503 }
1504
1505 sub fetch_related_holds {
1506     my $class = shift;
1507     my $copyid = shift;
1508     return $apputils->simplereq(
1509         'open-ils.cstore',
1510         'open-ils.cstore.direct.action.hold_request.search.atomic',
1511         { current_copy =>  $copyid , cancel_time => undef, fulfillment_time => undef });
1512 }
1513
1514
1515 __PACKAGE__->register_method(
1516     method    => "hold_pull_list",
1517     api_name  => "open-ils.circ.hold_pull_list.retrieve",
1518     signature => {
1519         desc   => 'Returns (reference to) a list of holds that need to be "pulled" by a given location. ' .
1520                   'The location is determined by the login session.',
1521         params => [
1522             { desc => 'Limit (optional)',  type => 'number'},
1523             { desc => 'Offset (optional)', type => 'number'},
1524         ],
1525         return => {
1526             desc => 'reference to a list of holds, or event on failure',
1527         }
1528     }
1529 );
1530
1531 __PACKAGE__->register_method(
1532     method    => "hold_pull_list",
1533     api_name  => "open-ils.circ.hold_pull_list.id_list.retrieve",
1534     signature => {
1535         desc   => 'Returns (reference to) a list of holds IDs that need to be "pulled" by a given location. ' .
1536                   'The location is determined by the login session.',
1537         params => [
1538             { desc => 'Limit (optional)',  type => 'number'},
1539             { desc => 'Offset (optional)', type => 'number'},
1540         ],
1541         return => {
1542             desc => 'reference to a list of holds, or event on failure',
1543         }
1544     }
1545 );
1546
1547 __PACKAGE__->register_method(
1548     method    => "hold_pull_list",
1549     api_name  => "open-ils.circ.hold_pull_list.retrieve.count",
1550     signature => {
1551         desc   => 'Returns a count of holds that need to be "pulled" by a given location. ' .
1552                   'The location is determined by the login session.',
1553         params => [
1554             { desc => 'Limit (optional)',  type => 'number'},
1555             { desc => 'Offset (optional)', type => 'number'},
1556         ],
1557         return => {
1558             desc => 'Holds count (integer), or event on failure',
1559             # type => 'number'
1560         }
1561     }
1562 );
1563
1564 __PACKAGE__->register_method(
1565     method    => "hold_pull_list",
1566     stream => 1,
1567     # TODO: tag with api_level 2 once fully supported
1568     api_name  => "open-ils.circ.hold_pull_list.fleshed.stream",
1569     signature => {
1570         desc   => q/Returns a stream of fleshed holds  that need to be 
1571                     "pulled" by a given location.  The location is 
1572                     determined by the login session.  
1573                     This API calls always run in authoritative mode./,
1574         params => [
1575             { desc => 'Limit (optional)',  type => 'number'},
1576             { desc => 'Offset (optional)', type => 'number'},
1577         ],
1578         return => {
1579             desc => 'Stream of holds holds, or event on failure',
1580         }
1581     }
1582 );
1583
1584 sub hold_pull_list {
1585     my( $self, $conn, $authtoken, $limit, $offset ) = @_;
1586     my( $reqr, $evt ) = $U->checkses($authtoken);
1587     return $evt if $evt;
1588
1589     my $org = $reqr->ws_ou || $reqr->home_ou;
1590     # the perm locaiton shouldn't really matter here since holds
1591     # will exist all over and VIEW_HOLDS should be universal
1592     $evt = $U->check_perms($reqr->id, $org, 'VIEW_HOLD');
1593     return $evt if $evt;
1594
1595     if($self->api_name =~ /count/) {
1596
1597         my $count = $U->storagereq(
1598             'open-ils.storage.direct.action.hold_request.pull_list.current_copy_circ_lib.status_filtered.count',
1599             $org, $limit, $offset );
1600
1601         $logger->info("Grabbing pull list for org unit $org with $count items");
1602         return $count;
1603
1604     } elsif( $self->api_name =~ /id_list/ ) {
1605         $U->storagereq(
1606             'open-ils.storage.direct.action.hold_request.pull_list.id_list.current_copy_circ_lib.status_filtered.atomic',
1607             $org, $limit, $offset );
1608
1609     } elsif ($self->api_name =~ /fleshed/) {
1610
1611         my $ids = $U->storagereq(
1612             'open-ils.storage.direct.action.hold_request.pull_list.id_list.current_copy_circ_lib.status_filtered.atomic',
1613             $org, $limit, $offset );
1614
1615         my $e = new_editor(xact => 1, requestor => $reqr);
1616         $conn->respond(uber_hold_impl($e, $_, {flesh_acpl => 1})) for @$ids;
1617         $e->rollback;
1618         $conn->respond_complete;
1619         return;
1620
1621     } else {
1622         $U->storagereq(
1623             'open-ils.storage.direct.action.hold_request.pull_list.search.current_copy_circ_lib.status_filtered.atomic',
1624             $org, $limit, $offset );
1625     }
1626 }
1627
1628 __PACKAGE__->register_method(
1629     method    => "print_hold_pull_list",
1630     api_name  => "open-ils.circ.hold_pull_list.print",
1631     signature => {
1632         desc   => 'Returns an HTML-formatted holds pull list',
1633         params => [
1634             { desc => 'Authtoken', type => 'string'},
1635             { desc => 'Org unit ID.  Optional, defaults to workstation org unit', type => 'number'},
1636         ],
1637         return => {
1638             desc => 'HTML string',
1639             type => 'string'
1640         }
1641     }
1642 );
1643
1644 sub print_hold_pull_list {
1645     my($self, $client, $auth, $org_id) = @_;
1646
1647     my $e = new_editor(authtoken=>$auth);
1648     return $e->event unless $e->checkauth;
1649
1650     $org_id = (defined $org_id) ? $org_id : $e->requestor->ws_ou;
1651     return $e->event unless $e->allowed('VIEW_HOLD', $org_id);
1652
1653     my $hold_ids = $U->storagereq(
1654         'open-ils.storage.direct.action.hold_request.pull_list.id_list.current_copy_circ_lib.status_filtered.atomic',
1655         $org_id, 10000);
1656
1657     return undef unless @$hold_ids;
1658
1659     $client->status(new OpenSRF::DomainObject::oilsContinueStatus);
1660
1661     # Holds will /NOT/ be in order after this ...
1662     my $holds = $e->search_action_hold_request({id => $hold_ids}, {substream => 1});
1663     $client->status(new OpenSRF::DomainObject::oilsContinueStatus);
1664
1665     # ... so we must resort.
1666     my $hold_map = +{map { $_->id => $_ } @$holds};
1667     my $sorted_holds = [];
1668     push @$sorted_holds, $hold_map->{$_} foreach @$hold_ids;
1669
1670     return $U->fire_object_event(
1671         undef, "ahr.format.pull_list", $sorted_holds,
1672         $org_id, undef, undef, $client
1673     );
1674
1675 }
1676
1677 __PACKAGE__->register_method(
1678     method    => "print_hold_pull_list_stream",
1679     stream   => 1,
1680     api_name  => "open-ils.circ.hold_pull_list.print.stream",
1681     signature => {
1682         desc   => 'Returns a stream of fleshed holds',
1683         params => [
1684             { desc => 'Authtoken', type => 'string'},
1685             { desc => 'Hash of optional param: Org unit ID (defaults to workstation org unit), limit, offset, sort (array of: acplo.position, prefix, call_number, suffix, request_time)',
1686               type => 'object'
1687             },
1688         ],
1689         return => {
1690             desc => 'A stream of fleshed holds',
1691             type => 'object'
1692         }
1693     }
1694 );
1695
1696 sub print_hold_pull_list_stream {
1697     my($self, $client, $auth, $params) = @_;
1698
1699     my $e = new_editor(authtoken=>$auth);
1700     return $e->die_event unless $e->checkauth;
1701
1702     delete($$params{org_id}) unless (int($$params{org_id}));
1703     delete($$params{limit}) unless (int($$params{limit}));
1704     delete($$params{offset}) unless (int($$params{offset}));
1705     delete($$params{chunk_size}) unless (int($$params{chunk_size}));
1706     delete($$params{chunk_size}) if  ($$params{chunk_size} && $$params{chunk_size} > 50); # keep the size reasonable
1707     $$params{chunk_size} ||= 10;
1708     $client->max_chunk_size($$params{chunk_size}) if (!$client->can('max_bundle_size') && $client->can('max_chunk_size'));
1709
1710     $$params{org_id} = (defined $$params{org_id}) ? $$params{org_id}: $e->requestor->ws_ou;
1711     return $e->die_event unless $e->allowed('VIEW_HOLD', $$params{org_id });
1712
1713     my $sort = [];
1714     if ($$params{sort} && @{ $$params{sort} }) {
1715         for my $s (@{ $$params{sort} }) {
1716             if ($s eq 'acplo.position') {
1717                 push @$sort, {
1718                     "class" => "acplo", "field" => "position",
1719                     "transform" => "coalesce", "params" => [999]
1720                 };
1721             } elsif ($s eq 'prefix') {
1722                 push @$sort, {"class" => "acnp", "field" => "label_sortkey"};
1723             } elsif ($s eq 'call_number') {
1724                 push @$sort, {"class" => "acn", "field" => "label_sortkey"};
1725             } elsif ($s eq 'suffix') {
1726                 push @$sort, {"class" => "acns", "field" => "label_sortkey"};
1727             } elsif ($s eq 'request_time') {
1728                 push @$sort, {"class" => "ahr", "field" => "request_time"};
1729             }
1730         }
1731     } else {
1732         push @$sort, {"class" => "ahr", "field" => "request_time"};
1733     }
1734
1735     my $holds_ids = $e->json_query(
1736         {
1737             "select" => {"ahr" => ["id"]},
1738             "from" => {
1739                 "ahr" => {
1740                     "acp" => {
1741                         "field" => "id",
1742                         "fkey" => "current_copy",
1743                         "filter" => {
1744                             "circ_lib" => $$params{org_id}, "status" => [0,7]
1745                         },
1746                         "join" => {
1747                             "acn" => {
1748                                 "field" => "id",
1749                                 "fkey" => "call_number",
1750                                 "join" => {
1751                                     "acnp" => {
1752                                         "field" => "id",
1753                                         "fkey" => "prefix"
1754                                     },
1755                                     "acns" => {
1756                                         "field" => "id",
1757                                         "fkey" => "suffix"
1758                                     }
1759                                 }
1760                             },
1761                             "acplo" => {
1762                                 "field" => "org",
1763                                 "fkey" => "circ_lib",
1764                                 "type" => "left",
1765                                 "filter" => {
1766                                     "location" => {"=" => {"+acp" => "location"}}
1767                                 }
1768                             }
1769                         }
1770                     }
1771                 }
1772             },
1773             "where" => {
1774                 "+ahr" => {
1775                     "capture_time" => undef,
1776                     "cancel_time" => undef,
1777                     "-or" => [
1778                         {"expire_time" => undef },
1779                         {"expire_time" => {">" => "now"}}
1780                     ]
1781                 }
1782             },
1783             (@$sort ? (order_by => $sort) : ()),
1784             ($$params{limit} ? (limit => $$params{limit}) : ()),
1785             ($$params{offset} ? (offset => $$params{offset}) : ())
1786         }, {"substream" => 1}
1787     ) or return $e->die_event;
1788
1789     $logger->info("about to stream back " . scalar(@$holds_ids) . " holds");
1790
1791     my @chunk;
1792     for my $hid (@$holds_ids) {
1793         push @chunk, $e->retrieve_action_hold_request([
1794             $hid->{"id"}, {
1795                 "flesh" => 3,
1796                 "flesh_fields" => {
1797                     "ahr" => ["usr", "current_copy"],
1798                     "au"  => ["card"],
1799                     "acp" => ["location", "call_number", "parts"],
1800                     "acn" => ["record","prefix","suffix"]
1801                 }
1802             }
1803         ]);
1804
1805         if (@chunk >= $$params{chunk_size}) {
1806             $client->respond( \@chunk );
1807             @chunk = ();
1808         }
1809     }
1810     $client->respond_complete( \@chunk ) if (@chunk);
1811     $e->disconnect;
1812     return undef;
1813 }
1814
1815
1816
1817 __PACKAGE__->register_method(
1818     method        => 'fetch_hold_notify',
1819     api_name      => 'open-ils.circ.hold_notification.retrieve_by_hold',
1820     authoritative => 1,
1821     signature     => q/
1822 Returns a list of hold notification objects based on hold id.
1823 @param authtoken The loggin session key
1824 @param holdid The id of the hold whose notifications we want to retrieve
1825 @return An array of hold notification objects, event on error.
1826 /
1827 );
1828
1829 sub fetch_hold_notify {
1830     my( $self, $conn, $authtoken, $holdid ) = @_;
1831     my( $requestor, $evt ) = $U->checkses($authtoken);
1832     return $evt if $evt;
1833     my ($hold, $patron);
1834     ($hold, $evt) = $U->fetch_hold($holdid);
1835     return $evt if $evt;
1836     ($patron, $evt) = $U->fetch_user($hold->usr);
1837     return $evt if $evt;
1838
1839     $evt = $U->check_perms($requestor->id, $patron->home_ou, 'VIEW_HOLD_NOTIFICATION');
1840     return $evt if $evt;
1841
1842     $logger->info("User ".$requestor->id." fetching hold notifications for hold $holdid");
1843     return $U->cstorereq(
1844         'open-ils.cstore.direct.action.hold_notification.search.atomic', {hold => $holdid} );
1845 }
1846
1847
1848 __PACKAGE__->register_method(
1849     method    => 'create_hold_notify',
1850     api_name  => 'open-ils.circ.hold_notification.create',
1851     signature => q/
1852 Creates a new hold notification object
1853 @param authtoken The login session key
1854 @param notification The hold notification object to create
1855 @return ID of the new object on success, Event on error
1856 /
1857 );
1858
1859 sub create_hold_notify {
1860    my( $self, $conn, $auth, $note ) = @_;
1861    my $e = new_editor(authtoken=>$auth, xact=>1);
1862    return $e->die_event unless $e->checkauth;
1863
1864    my $hold = $e->retrieve_action_hold_request($note->hold)
1865       or return $e->die_event;
1866    my $patron = $e->retrieve_actor_user($hold->usr)
1867       or return $e->die_event;
1868
1869    return $e->die_event unless
1870       $e->allowed('CREATE_HOLD_NOTIFICATION', $patron->home_ou);
1871
1872    $note->notify_staff($e->requestor->id);
1873    $e->create_action_hold_notification($note) or return $e->die_event;
1874    $e->commit;
1875    return $note->id;
1876 }
1877
1878 __PACKAGE__->register_method(
1879     method    => 'create_hold_note',
1880     api_name  => 'open-ils.circ.hold_note.create',
1881     signature => q/
1882         Creates a new hold request note object
1883         @param authtoken The login session key
1884         @param note The hold note object to create
1885         @return ID of the new object on success, Event on error
1886         /
1887 );
1888
1889 sub create_hold_note {
1890    my( $self, $conn, $auth, $note ) = @_;
1891    my $e = new_editor(authtoken=>$auth, xact=>1);
1892    return $e->die_event unless $e->checkauth;
1893
1894    my $hold = $e->retrieve_action_hold_request($note->hold)
1895       or return $e->die_event;
1896    my $patron = $e->retrieve_actor_user($hold->usr)
1897       or return $e->die_event;
1898
1899    return $e->die_event unless
1900       $e->allowed('UPDATE_HOLD', $patron->home_ou); # FIXME: Using permcrud perm listed in fm_IDL.xml for ahrn.  Probably want something more specific
1901
1902    $e->create_action_hold_request_note($note) or return $e->die_event;
1903    $e->commit;
1904    return $note->id;
1905 }
1906
1907 __PACKAGE__->register_method(
1908     method    => 'reset_hold',
1909     api_name  => 'open-ils.circ.hold.reset',
1910     signature => q/
1911         Un-captures and un-targets a hold, essentially returning
1912         it to the state it was in directly after it was placed,
1913         then attempts to re-target the hold
1914         @param authtoken The login session key
1915         @param holdid The id of the hold
1916     /
1917 );
1918
1919
1920 sub reset_hold {
1921     my( $self, $conn, $auth, $holdid ) = @_;
1922     my $reqr;
1923     my ($hold, $evt) = $U->fetch_hold($holdid);
1924     return $evt if $evt;
1925     ($reqr, $evt) = $U->checksesperm($auth, 'UPDATE_HOLD');
1926     return $evt if $evt;
1927     $evt = _reset_hold($self, $reqr, $hold);
1928     return $evt if $evt;
1929     return 1;
1930 }
1931
1932
1933 __PACKAGE__->register_method(
1934     method   => 'reset_hold_batch',
1935     api_name => 'open-ils.circ.hold.reset.batch'
1936 );
1937
1938 sub reset_hold_batch {
1939     my($self, $conn, $auth, $hold_ids) = @_;
1940
1941     my $e = new_editor(authtoken => $auth);
1942     return $e->event unless $e->checkauth;
1943
1944     for my $hold_id ($hold_ids) {
1945
1946         my $hold = $e->retrieve_action_hold_request(
1947             [$hold_id, {flesh => 1, flesh_fields => {ahr => ['usr']}}])
1948             or return $e->event;
1949
1950         next unless $e->allowed('UPDATE_HOLD', $hold->usr->home_ou);
1951         _reset_hold($self, $e->requestor, $hold);
1952     }
1953
1954     return 1;
1955 }
1956
1957
1958 sub _reset_hold {
1959     my ($self, $reqr, $hold) = @_;
1960
1961     my $e = new_editor(xact =>1, requestor => $reqr);
1962
1963     $logger->info("reseting hold ".$hold->id);
1964
1965     my $hid = $hold->id;
1966
1967     if( $hold->capture_time and $hold->current_copy ) {
1968
1969         my $copy = $e->retrieve_asset_copy($hold->current_copy)
1970             or return $e->die_event;
1971
1972         if( $copy->status == OILS_COPY_STATUS_ON_HOLDS_SHELF ) {
1973             $logger->info("setting copy to status 'reshelving' on hold retarget");
1974             $copy->status(OILS_COPY_STATUS_RESHELVING);
1975             $copy->editor($e->requestor->id);
1976             $copy->edit_date('now');
1977             $e->update_asset_copy($copy) or return $e->die_event;
1978
1979         } elsif( $copy->status == OILS_COPY_STATUS_IN_TRANSIT ) {
1980
1981             $logger->warn("! reseting hold [$hid] that is in transit");
1982             my $transid = $e->search_action_hold_transit_copy({hold=>$hold->id,cancel_time=>undef},{idlist=>1})->[0];
1983
1984             if( $transid ) {
1985                 my $trans = $e->retrieve_action_transit_copy($transid);
1986                 if( $trans ) {
1987                     $logger->info("Aborting transit [$transid] on hold [$hid] reset...");
1988                     my $evt = OpenILS::Application::Circ::Transit::__abort_transit($e, $trans, $copy, 1, 1);
1989                     $logger->info("Transit abort completed with result $evt");
1990                     unless ("$evt" eq 1) {
1991                         $e->rollback;
1992                         return $evt;
1993                     }
1994                 }
1995             }
1996         }
1997     }
1998
1999     $hold->clear_capture_time;
2000     $hold->clear_current_copy;
2001     $hold->clear_shelf_time;
2002     $hold->clear_shelf_expire_time;
2003     $hold->clear_current_shelf_lib;
2004
2005     $e->update_action_hold_request($hold) or return $e->die_event;
2006     $e->commit;
2007
2008     $U->simplereq('open-ils.hold-targeter', 
2009         'open-ils.hold-targeter.target', {hold => $hold->id});
2010
2011     return undef;
2012 }
2013
2014
2015 __PACKAGE__->register_method(
2016     method    => 'fetch_open_title_holds',
2017     api_name  => 'open-ils.circ.open_holds.retrieve',
2018     signature => q/
2019         Returns a list ids of un-fulfilled holds for a given title id
2020         @param authtoken The login session key
2021         @param id the id of the item whose holds we want to retrieve
2022         @param type The hold type - M, T, I, V, C, F, R
2023     /
2024 );
2025
2026 sub fetch_open_title_holds {
2027     my( $self, $conn, $auth, $id, $type, $org ) = @_;
2028     my $e = new_editor( authtoken => $auth );
2029     return $e->event unless $e->checkauth;
2030
2031     $type ||= "T";
2032     $org  ||= $e->requestor->ws_ou;
2033
2034 #    return $e->search_action_hold_request(
2035 #        { target => $id, hold_type => $type, fulfillment_time => undef }, {idlist=>1});
2036
2037     # XXX make me return IDs in the future ^--
2038     my $holds = $e->search_action_hold_request(
2039         {
2040             target           => $id,
2041             cancel_time      => undef,
2042             hold_type        => $type,
2043             fulfillment_time => undef
2044         }
2045     );
2046
2047     flesh_hold_transits($holds);
2048     return $holds;
2049 }
2050
2051
2052 sub flesh_hold_transits {
2053     my $holds = shift;
2054     for my $hold ( @$holds ) {
2055         $hold->transit(
2056             $apputils->simplereq(
2057                 'open-ils.cstore',
2058                 "open-ils.cstore.direct.action.hold_transit_copy.search.atomic",
2059                 { hold => $hold->id, cancel_time => undef },
2060                 { order_by => { ahtc => 'id desc' }, limit => 1 }
2061             )->[0]
2062         );
2063     }
2064 }
2065
2066 sub flesh_hold_notices {
2067     my( $holds, $e ) = @_;
2068     $e ||= new_editor();
2069
2070     for my $hold (@$holds) {
2071         my $notices = $e->search_action_hold_notification(
2072             [
2073                 { hold => $hold->id },
2074                 { order_by => { anh => 'notify_time desc' } },
2075             ],
2076             {idlist=>1}
2077         );
2078
2079         $hold->notify_count(scalar(@$notices));
2080         if( @$notices ) {
2081             my $n = $e->retrieve_action_hold_notification($$notices[0])
2082                 or return $e->event;
2083             $hold->notify_time($n->notify_time);
2084         }
2085     }
2086 }
2087
2088
2089 __PACKAGE__->register_method(
2090     method    => 'fetch_captured_holds',
2091     api_name  => 'open-ils.circ.captured_holds.on_shelf.retrieve',
2092     stream    => 1,
2093     authoritative => 1,
2094     signature => q/
2095         Returns a list of un-fulfilled holds (on the Holds Shelf) for a given title id
2096         @param authtoken The login session key
2097         @param org The org id of the location in question
2098         @param match_copy A specific copy to limit to
2099     /
2100 );
2101
2102 __PACKAGE__->register_method(
2103     method    => 'fetch_captured_holds',
2104     api_name  => 'open-ils.circ.captured_holds.id_list.on_shelf.retrieve',
2105     stream    => 1,
2106     authoritative => 1,
2107     signature => q/
2108         Returns list ids of un-fulfilled holds (on the Holds Shelf) for a given title id
2109         @param authtoken The login session key
2110         @param org The org id of the location in question
2111         @param match_copy A specific copy to limit to
2112     /
2113 );
2114
2115 __PACKAGE__->register_method(
2116     method    => 'fetch_captured_holds',
2117     api_name  => 'open-ils.circ.captured_holds.id_list.expired_on_shelf.retrieve',
2118     stream    => 1,
2119     authoritative => 1,
2120     signature => q/
2121         Returns list ids of shelf-expired un-fulfilled holds for a given title id
2122         @param authtoken The login session key
2123         @param org The org id of the location in question
2124         @param match_copy A specific copy to limit to
2125     /
2126 );
2127
2128 __PACKAGE__->register_method(
2129     method    => 'fetch_captured_holds',
2130     api_name  => 
2131       'open-ils.circ.captured_holds.expired_on_shelf_or_wrong_shelf.retrieve',
2132     stream    => 1,
2133     authoritative => 1,
2134     signature => q/
2135         Returns list of shelf-expired un-fulfilled holds OR wrong shelf holds
2136         for a given shelf lib
2137     /
2138 );
2139
2140 __PACKAGE__->register_method(
2141     method    => 'fetch_captured_holds',
2142     api_name  => 
2143       'open-ils.circ.captured_holds.id_list.expired_on_shelf_or_wrong_shelf.retrieve',
2144     stream    => 1,
2145     authoritative => 1,
2146     signature => q/
2147         Returns list of shelf-expired un-fulfilled holds OR wrong shelf holds
2148         for a given shelf lib
2149     /
2150 );
2151
2152
2153 sub fetch_captured_holds {
2154     my( $self, $conn, $auth, $org, $match_copy ) = @_;
2155
2156     my $e = new_editor(authtoken => $auth);
2157     return $e->die_event unless $e->checkauth;
2158     return $e->die_event unless $e->allowed('VIEW_HOLD'); # XXX rely on editor perm
2159
2160     $org ||= $e->requestor->ws_ou;
2161
2162     my $current_copy = { '!=' => undef };
2163     $current_copy = { '=' => $match_copy } if $match_copy;
2164
2165     my $query = {
2166         select => { alhr => ['id'] },
2167         from   => {
2168             alhr => {
2169                 acp => {
2170                     field => 'id',
2171                     fkey  => 'current_copy'
2172                 },
2173             }
2174         },
2175         where => {
2176             '+acp' => { status => OILS_COPY_STATUS_ON_HOLDS_SHELF, deleted => 'f' },
2177             '+alhr' => {
2178                 capture_time      => { "!=" => undef },
2179                 current_copy      => $current_copy,
2180                 fulfillment_time  => undef,
2181                 current_shelf_lib => $org
2182             }
2183         }
2184     };
2185     if($self->api_name =~ /expired/) {
2186         $query->{'where'}->{'+alhr'}->{'-or'} = {
2187                 shelf_expire_time => { '<' => 'today'},
2188                 cancel_time => { '!=' => undef },
2189         };
2190     }
2191     my $hold_ids = $e->json_query( $query );
2192
2193     if ($self->api_name =~ /wrong_shelf/) {
2194         # fetch holds whose current_shelf_lib is $org, but whose pickup 
2195         # lib is some other org unit.  Ignore already-retrieved holds.
2196         my $wrong_shelf =
2197             pickup_lib_changed_on_shelf_holds(
2198                 $e, $org, [map {$_->{id}} @$hold_ids]);
2199         # match the layout of other items in $hold_ids
2200         push (@$hold_ids, {id => $_}) for @$wrong_shelf;
2201     }
2202
2203
2204     for my $hold_id (@$hold_ids) {
2205         if($self->api_name =~ /id_list/) {
2206             $conn->respond($hold_id->{id});
2207             next;
2208         } else {
2209             $conn->respond(
2210                 $e->retrieve_action_hold_request([
2211                     $hold_id->{id},
2212                     {
2213                         flesh => 1,
2214                         flesh_fields => {ahr => ['notifications', 'transit', 'notes']},
2215                         order_by => {anh => 'notify_time desc'}
2216                     }
2217                 ])
2218             );
2219         }
2220     }
2221
2222     return undef;
2223 }
2224
2225 __PACKAGE__->register_method(
2226     method    => "print_expired_holds_stream",
2227     api_name  => "open-ils.circ.captured_holds.expired.print.stream",
2228     stream    => 1
2229 );
2230
2231 sub print_expired_holds_stream {
2232     my ($self, $client, $auth, $params) = @_;
2233
2234     # No need to check specific permissions: we're going to call another method
2235     # that will do that.
2236     my $e = new_editor("authtoken" => $auth);
2237     return $e->die_event unless $e->checkauth;
2238
2239     delete($$params{org_id}) unless (int($$params{org_id}));
2240     delete($$params{limit}) unless (int($$params{limit}));
2241     delete($$params{offset}) unless (int($$params{offset}));
2242     delete($$params{chunk_size}) unless (int($$params{chunk_size}));
2243     delete($$params{chunk_size}) if  ($$params{chunk_size} && $$params{chunk_size} > 50); # keep the size reasonable
2244     $$params{chunk_size} ||= 10;
2245     $client->max_chunk_size($$params{chunk_size}) if (!$client->can('max_bundle_size') && $client->can('max_chunk_size'));
2246
2247     $$params{org_id} = (defined $$params{org_id}) ? $$params{org_id}: $e->requestor->ws_ou;
2248
2249     my @hold_ids = $self->method_lookup(
2250         "open-ils.circ.captured_holds.id_list.expired_on_shelf.retrieve"
2251     )->run($auth, $params->{"org_id"});
2252
2253     if (!@hold_ids) {
2254         $e->disconnect;
2255         return;
2256     } elsif (defined $U->event_code($hold_ids[0])) {
2257         $e->disconnect;
2258         return $hold_ids[0];
2259     }
2260
2261     $logger->info("about to stream back up to " . scalar(@hold_ids) . " expired holds");
2262
2263     while (@hold_ids) {
2264         my @hid_chunk = splice @hold_ids, 0, $params->{"chunk_size"};
2265
2266         my $result_chunk = $e->json_query({
2267             "select" => {
2268                 "acp" => ["barcode"],
2269                 "au" => [qw/
2270                     first_given_name second_given_name family_name alias
2271                 /],
2272                 "acn" => ["label"],
2273                 "bre" => ["marc"],
2274                 "acpl" => ["name"]
2275             },
2276             "from" => {
2277                 "ahr" => {
2278                     "acp" => {
2279                         "field" => "id", "fkey" => "current_copy",
2280                         "join" => {
2281                             "acn" => {
2282                                 "field" => "id", "fkey" => "call_number",
2283                                 "join" => {
2284                                     "bre" => {
2285                                         "field" => "id", "fkey" => "record"
2286                                     }
2287                                 }
2288                             },
2289                             "acpl" => {"field" => "id", "fkey" => "location"}
2290                         }
2291                     },
2292                     "au" => {"field" => "id", "fkey" => "usr"}
2293                 }
2294             },
2295             "where" => {"+ahr" => {"id" => \@hid_chunk}}
2296         }) or return $e->die_event;
2297         $client->respond($result_chunk);
2298     }
2299
2300     $e->disconnect;
2301     undef;
2302 }
2303
2304 __PACKAGE__->register_method(
2305     method    => "check_title_hold_batch",
2306     api_name  => "open-ils.circ.title_hold.is_possible.batch",
2307     stream    => 1,
2308     signature => {
2309         desc  => '@see open-ils.circ.title_hold.is_possible.batch',
2310         params => [
2311             { desc => 'Authentication token',     type => 'string'},
2312             { desc => 'Array of Hash of named parameters', type => 'array'},
2313         ],
2314         return => {
2315             desc => 'Array of response objects',
2316             type => 'array'
2317         }
2318     }
2319 );
2320
2321 sub check_title_hold_batch {
2322     my($self, $client, $authtoken, $param_list, $oargs) = @_;
2323     foreach (@$param_list) {
2324         my ($res) = $self->method_lookup('open-ils.circ.title_hold.is_possible')->run($authtoken, $_, $oargs);
2325         $client->respond($res);
2326     }
2327     return undef;
2328 }
2329
2330
2331 __PACKAGE__->register_method(
2332     method    => "check_title_hold",
2333     api_name  => "open-ils.circ.title_hold.is_possible",
2334     signature => {
2335         desc  => 'Determines if a hold were to be placed by a given user, ' .
2336              'whether or not said hold would have any potential copies to fulfill it.' .
2337              'The named paramaters of the second argument include: ' .
2338              'patronid, titleid, volume_id, copy_id, mrid, depth, pickup_lib, hold_type, selection_ou. ' .
2339              'See perldoc ' . __PACKAGE__ . ' for more info on these fields.' ,
2340         params => [
2341             { desc => 'Authentication token',     type => 'string'},
2342             { desc => 'Hash of named parameters', type => 'object'},
2343         ],
2344         return => {
2345             desc => 'List of new message IDs (empty if none)',
2346             type => 'array'
2347         }
2348     }
2349 );
2350
2351 =head3 check_title_hold (token, hash)
2352
2353 The named fields in the hash are:
2354
2355  patronid     - ID of the hold recipient  (required)
2356  depth        - hold range depth          (default 0)
2357  pickup_lib   - destination for hold, fallback value for selection_ou
2358  selection_ou - ID of org_unit establishing hard and soft hold boundary settings
2359  issuanceid   - ID of the issuance to be held, required for Issuance level hold
2360  partid       - ID of the monograph part to be held, required for monograph part level hold
2361  titleid      - ID (BRN) of the title to be held, required for Title level hold
2362  volume_id    - required for Volume level hold
2363  copy_id      - required for Copy level hold
2364  mrid         - required for Meta-record level hold
2365  hold_type    - T, C (or R or F), I, V or M for Title, Copy, Issuance, Volume or Meta-record  (default "T")
2366
2367 All key/value pairs are passed on to do_possibility_checks.
2368
2369 =cut
2370
2371 # FIXME: better params checking.  what other params are required, if any?
2372 # FIXME: 3 copies of values confusing: $x, $params->{x} and $params{x}
2373 # FIXME: for example, $depth gets a default value, but then $$params{depth} is still
2374 # used in conditionals, where it may be undefined, causing a warning.
2375 # FIXME: specify proper usage/interaction of selection_ou and pickup_lib
2376
2377 sub check_title_hold {
2378     my( $self, $client, $authtoken, $params ) = @_;
2379     my $e = new_editor(authtoken=>$authtoken);
2380     return $e->event unless $e->checkauth;
2381
2382     my %params       = %$params;
2383     my $depth        = $params{depth}        || 0;
2384     $params{depth} = $depth;   #define $params{depth} if unset, since it gets used later
2385     my $selection_ou = $params{selection_ou} || $params{pickup_lib};
2386     my $oargs        = $params{oargs}        || {};
2387
2388     if($oargs->{events}) {
2389         @{$oargs->{events}} = grep { $e->allowed($_ . '.override', $e->requestor->ws_ou); } @{$oargs->{events}};
2390     }
2391
2392
2393     my $patron = $e->retrieve_actor_user($params{patronid})
2394         or return $e->event;
2395
2396     if( $e->requestor->id ne $patron->id ) {
2397         return $e->event unless
2398             $e->allowed('VIEW_HOLD_PERMIT', $patron->home_ou);
2399     }
2400
2401     return OpenILS::Event->new('PATRON_BARRED') if $U->is_true($patron->barred);
2402
2403     my $request_lib = $e->retrieve_actor_org_unit($e->requestor->ws_ou)
2404         or return $e->event;
2405
2406     my $soft_boundary = $U->ou_ancestor_setting_value($selection_ou, OILS_SETTING_HOLD_SOFT_BOUNDARY);
2407     my $hard_boundary = $U->ou_ancestor_setting_value($selection_ou, OILS_SETTING_HOLD_HARD_BOUNDARY);
2408
2409     my @status = ();
2410     my $return_depth = $hard_boundary; # default depth to return on success
2411     if(defined $soft_boundary and $depth < $soft_boundary) {
2412         # work up the tree and as soon as we find a potential copy, use that depth
2413         # also, make sure we don't go past the hard boundary if it exists
2414
2415         # our min boundary is the greater of user-specified boundary or hard boundary
2416         my $min_depth = (defined $hard_boundary and $hard_boundary > $depth) ?
2417             $hard_boundary : $depth;
2418
2419         my $depth = $soft_boundary;
2420         while($depth >= $min_depth) {
2421             $logger->info("performing hold possibility check with soft boundary $depth");
2422             @status = do_possibility_checks($e, $patron, $request_lib, $depth, %params);
2423             if ($status[0]) {
2424                 $return_depth = $depth;
2425                 last;
2426             }
2427             $depth--;
2428         }
2429     } elsif(defined $hard_boundary and $depth < $hard_boundary) {
2430         # there is no soft boundary, enforce the hard boundary if it exists
2431         $logger->info("performing hold possibility check with hard boundary $hard_boundary");
2432         @status = do_possibility_checks($e, $patron, $request_lib, $hard_boundary, %params);
2433     } else {
2434         # no boundaries defined, fall back to user specifed boundary or no boundary
2435         $logger->info("performing hold possibility check with no boundary");
2436         @status = do_possibility_checks($e, $patron, $request_lib, $params{depth}, %params);
2437     }
2438
2439     my $place_unfillable = 0;
2440     $place_unfillable = 1 if $e->allowed('PLACE_UNFILLABLE_HOLD', $e->requestor->ws_ou);
2441
2442     if ($status[0]) {
2443         return {
2444             "success" => 1,
2445             "depth" => $return_depth,
2446             "local_avail" => $status[1]
2447         };
2448     } elsif ($status[2]) {
2449         my $n = scalar @{$status[2]};
2450         return {"success" => 0, "last_event" => $status[2]->[$n - 1], "age_protected_copy" => $status[3], "place_unfillable" => $place_unfillable};
2451     } else {
2452         return {"success" => 0, "age_protected_copy" => $status[3], "place_unfillable" => $place_unfillable};
2453     }
2454 }
2455
2456
2457
2458 sub do_possibility_checks {
2459     my($e, $patron, $request_lib, $depth, %params) = @_;
2460
2461     my $issuanceid   = $params{issuanceid}      || "";
2462     my $partid       = $params{partid}      || "";
2463     my $titleid      = $params{titleid}      || "";
2464     my $volid        = $params{volume_id};
2465     my $copyid       = $params{copy_id};
2466     my $mrid         = $params{mrid}         || "";
2467     my $pickup_lib   = $params{pickup_lib};
2468     my $hold_type    = $params{hold_type}    || 'T';
2469     my $selection_ou = $params{selection_ou} || $pickup_lib;
2470     my $holdable_formats = $params{holdable_formats};
2471     my $oargs        = $params{oargs}        || {};
2472
2473
2474     my $copy;
2475     my $volume;
2476     my $title;
2477
2478     if( $hold_type eq OILS_HOLD_TYPE_FORCE || $hold_type eq OILS_HOLD_TYPE_RECALL || $hold_type eq OILS_HOLD_TYPE_COPY ) {
2479
2480         return $e->event unless $copy   = $e->retrieve_asset_copy($copyid);
2481         return $e->event unless $volume = $e->retrieve_asset_call_number($copy->call_number);
2482         return $e->event unless $title  = $e->retrieve_biblio_record_entry($volume->record);
2483
2484         return (1, 1, []) if( $hold_type eq OILS_HOLD_TYPE_RECALL || $hold_type eq OILS_HOLD_TYPE_FORCE);
2485         return verify_copy_for_hold(
2486             $patron, $e->requestor, $title, $copy, $pickup_lib, $request_lib, $oargs
2487         );
2488
2489     } elsif( $hold_type eq OILS_HOLD_TYPE_VOLUME ) {
2490
2491         return $e->event unless $volume = $e->retrieve_asset_call_number($volid);
2492         return $e->event unless $title  = $e->retrieve_biblio_record_entry($volume->record);
2493
2494         return _check_volume_hold_is_possible(
2495             $volume, $title, $depth, $request_lib, $patron, $e->requestor, $pickup_lib, $selection_ou, $oargs
2496         );
2497
2498     } elsif( $hold_type eq OILS_HOLD_TYPE_TITLE ) {
2499
2500         return _check_title_hold_is_possible(
2501             $titleid, $depth, $request_lib, $patron, $e->requestor, $pickup_lib, $selection_ou, undef, $oargs
2502         );
2503
2504     } elsif( $hold_type eq OILS_HOLD_TYPE_ISSUANCE ) {
2505
2506         return _check_issuance_hold_is_possible(
2507             $issuanceid, $depth, $request_lib, $patron, $e->requestor, $pickup_lib, $selection_ou, $oargs
2508         );
2509
2510     } elsif( $hold_type eq OILS_HOLD_TYPE_MONOPART ) {
2511
2512         return _check_monopart_hold_is_possible(
2513             $partid, $depth, $request_lib, $patron, $e->requestor, $pickup_lib, $selection_ou, $oargs
2514         );
2515
2516     } elsif( $hold_type eq OILS_HOLD_TYPE_METARECORD ) {
2517
2518         # pasing undef as the depth to filtered_records causes the depth
2519         # of the selection_ou to be used, which is not what we want here.
2520         $depth ||= 0;
2521
2522         my ($recs) = __PACKAGE__->method_lookup('open-ils.circ.holds.metarecord.filtered_records')->run($mrid, $holdable_formats, $selection_ou, $depth);
2523         my @status = ();
2524         for my $rec (@$recs) {
2525             @status = _check_title_hold_is_possible(
2526                 $rec, $depth, $request_lib, $patron, $e->requestor, $pickup_lib, $selection_ou, $holdable_formats, $oargs
2527             );
2528             last if $status[0];
2529         }
2530         return @status;
2531     }
2532 #   else { Unrecognized hold_type ! }   # FIXME: return error? or 0?
2533 }
2534
2535 sub MR_filter_records {
2536     my $self = shift;
2537     my $client = shift;
2538     my $m = shift;
2539     my $f = shift;
2540     my $o = shift;
2541     my $d = shift;
2542     my $opac_visible = shift;
2543     
2544     my $org_at_depth = defined($d) ? $U->org_unit_ancestor_at_depth($o, $d) : $o;
2545     return $U->storagereq(
2546         'open-ils.storage.metarecord.filtered_records.atomic', 
2547         $m, $f, $org_at_depth, $opac_visible
2548     );
2549 }
2550 __PACKAGE__->register_method(
2551     method   => 'MR_filter_records',
2552     api_name => 'open-ils.circ.holds.metarecord.filtered_records',
2553 );
2554
2555
2556 my %prox_cache;
2557 sub create_ranged_org_filter {
2558     my($e, $selection_ou, $depth) = @_;
2559
2560     # find the orgs from which this hold may be fulfilled,
2561     # based on the selection_ou and depth
2562
2563     my $top_org = $e->search_actor_org_unit([
2564         {parent_ou => undef},
2565         {flesh=>1, flesh_fields=>{aou=>['ou_type']}}])->[0];
2566     my %org_filter;
2567
2568     return () if $depth == $top_org->ou_type->depth;
2569
2570     my $org_list = $U->storagereq('open-ils.storage.actor.org_unit.descendants.atomic', $selection_ou, $depth);
2571     %org_filter = (circ_lib => []);
2572     push(@{$org_filter{circ_lib}}, $_->id) for @$org_list;
2573
2574     $logger->info("hold org filter at depth $depth and selection_ou ".
2575         "$selection_ou created list of @{$org_filter{circ_lib}}");
2576
2577     return %org_filter;
2578 }
2579
2580
2581 sub _check_title_hold_is_possible {
2582     my( $titleid, $depth, $request_lib, $patron, $requestor, $pickup_lib, $selection_ou, $holdable_formats, $oargs ) = @_;
2583     # $holdable_formats is now unused. We pre-filter the MR's records.
2584
2585     my $e = new_editor();
2586     my %org_filter = create_ranged_org_filter($e, $selection_ou, $depth);
2587
2588     # this monster will grab the id and circ_lib of all of the "holdable" copies for the given record
2589     my $copies = $e->json_query(
2590         {
2591             select => { acp => ['id', 'circ_lib'] },
2592               from => {
2593                 acp => {
2594                     acn => {
2595                         field  => 'id',
2596                         fkey   => 'call_number',
2597                         filter => { record => $titleid }
2598                     },
2599                     acpl => {
2600                                 field => 'id',
2601                                 filter => { holdable => 't', deleted => 'f' },
2602                                 fkey => 'location'
2603                             },
2604                     ccs  => { field => 'id', filter => { holdable => 't'}, fkey => 'status'   },
2605                     acpm => { field => 'target_copy', type => 'left' } # ignore part-linked copies
2606                 }
2607             },
2608             where => {
2609                 '+acp' => { circulate => 't', deleted => 'f', holdable => 't', %org_filter },
2610                 '+acpm' => { target_copy => undef } # ignore part-linked copies
2611             }
2612         }
2613     );
2614
2615     $logger->info("title possible found ".scalar(@$copies)." potential copies");
2616     return (
2617         0, 0, [
2618             new OpenILS::Event(
2619                 "HIGH_LEVEL_HOLD_HAS_NO_COPIES",
2620                 "payload" => {"fail_part" => "no_ultimate_items"}
2621             )
2622         ]
2623     ) unless @$copies;
2624
2625     # -----------------------------------------------------------------------
2626     # sort the copies into buckets based on their circ_lib proximity to
2627     # the patron's home_ou.
2628     # -----------------------------------------------------------------------
2629
2630     my $home_org = $patron->home_ou;
2631     my $req_org = $request_lib->id;
2632
2633     $prox_cache{$home_org} =
2634         $e->search_actor_org_unit_proximity({from_org => $home_org})
2635         unless $prox_cache{$home_org};
2636     my $home_prox = $prox_cache{$home_org};
2637     $logger->info("prox cache $home_org " . $prox_cache{$home_org});
2638
2639     my %buckets;
2640     my %hash = map { ($_->to_org => $_->prox) } @$home_prox;
2641     push( @{$buckets{ $hash{$_->{circ_lib}} } }, $_->{id} ) for @$copies;
2642
2643     my @keys = sort { $a <=> $b } keys %buckets;
2644
2645
2646     if( $home_org ne $req_org ) {
2647       # -----------------------------------------------------------------------
2648       # shove the copies close to the request_lib into the primary buckets
2649       # directly before the farthest away copies.  That way, they are not
2650       # given priority, but they are checked before the farthest copies.
2651       # -----------------------------------------------------------------------
2652         $prox_cache{$req_org} =
2653             $e->search_actor_org_unit_proximity({from_org => $req_org})
2654             unless $prox_cache{$req_org};
2655         my $req_prox = $prox_cache{$req_org};
2656
2657         my %buckets2;
2658         my %hash2 = map { ($_->to_org => $_->prox) } @$req_prox;
2659         push( @{$buckets2{ $hash2{$_->{circ_lib}} } }, $_->{id} ) for @$copies;
2660
2661         my $highest_key = $keys[@keys - 1];  # the farthest prox in the exising buckets
2662         my $new_key = $highest_key - 0.5; # right before the farthest prox
2663         my @keys2   = sort { $a <=> $b } keys %buckets2;
2664         for my $key (@keys2) {
2665             last if $key >= $highest_key;
2666             push( @{$buckets{$new_key}}, $_ ) for @{$buckets2{$key}};
2667         }
2668     }
2669
2670     @keys = sort { $a <=> $b } keys %buckets;
2671
2672     my $title;
2673     my %seen;
2674     my @status;
2675     my $age_protect_only = 0;
2676     OUTER: for my $key (@keys) {
2677       my @cps = @{$buckets{$key}};
2678
2679       $logger->info("looking at " . scalar(@{$buckets{$key}}). " copies in proximity bucket $key");
2680
2681       for my $copyid (@cps) {
2682
2683          next if $seen{$copyid};
2684          $seen{$copyid} = 1; # there could be dupes given the merged buckets
2685          my $copy = $e->retrieve_asset_copy($copyid);
2686          $logger->debug("looking at bucket_key=$key, copy $copyid : circ_lib = " . $copy->circ_lib);
2687
2688          unless($title) { # grab the title if we don't already have it
2689             my $vol = $e->retrieve_asset_call_number(
2690                [ $copy->call_number, { flesh => 1, flesh_fields => { bre => ['fixed_fields'], acn => ['record'] } } ] );
2691             $title = $vol->record;
2692          }
2693
2694          @status = verify_copy_for_hold(
2695             $patron, $requestor, $title, $copy, $pickup_lib, $request_lib, $oargs);
2696
2697          $age_protect_only ||= $status[3];
2698          last OUTER if $status[0];
2699       }
2700     }
2701
2702     $status[3] = $age_protect_only;
2703     return @status;
2704 }
2705
2706 sub _check_issuance_hold_is_possible {
2707     my( $issuanceid, $depth, $request_lib, $patron, $requestor, $pickup_lib, $selection_ou, $oargs ) = @_;
2708
2709     my $e = new_editor();
2710     my %org_filter = create_ranged_org_filter($e, $selection_ou, $depth);
2711
2712     # this monster will grab the id and circ_lib of all of the "holdable" copies for the given record
2713     my $copies = $e->json_query(
2714         {
2715             select => { acp => ['id', 'circ_lib'] },
2716               from => {
2717                 acp => {
2718                     sitem => {
2719                         field  => 'unit',
2720                         fkey   => 'id',
2721                         filter => { issuance => $issuanceid }
2722                     },
2723                     acpl => {
2724                         field => 'id',
2725                         filter => { holdable => 't', deleted => 'f' },
2726                         fkey => 'location'
2727                     },
2728                     ccs  => { field => 'id', filter => { holdable => 't'}, fkey => 'status'   }
2729                 }
2730             },
2731             where => {
2732                 '+acp' => { circulate => 't', deleted => 'f', holdable => 't', %org_filter }
2733             },
2734             distinct => 1
2735         }
2736     );
2737
2738     $logger->info("issuance possible found ".scalar(@$copies)." potential copies");
2739
2740     my $empty_ok;
2741     if (!@$copies) {
2742         $empty_ok = $e->retrieve_config_global_flag('circ.holds.empty_issuance_ok');
2743         $empty_ok = ($empty_ok and $U->is_true($empty_ok->enabled));
2744
2745         return (
2746             0, 0, [
2747                 new OpenILS::Event(
2748                     "HIGH_LEVEL_HOLD_HAS_NO_COPIES",
2749                     "payload" => {"fail_part" => "no_ultimate_items"}
2750                 )
2751             ]
2752         ) unless $empty_ok;
2753
2754         return (1, 0);
2755     }
2756
2757     # -----------------------------------------------------------------------
2758     # sort the copies into buckets based on their circ_lib proximity to
2759     # the patron's home_ou.
2760     # -----------------------------------------------------------------------
2761
2762     my $home_org = $patron->home_ou;
2763     my $req_org = $request_lib->id;
2764
2765     $prox_cache{$home_org} =
2766         $e->search_actor_org_unit_proximity({from_org => $home_org})
2767         unless $prox_cache{$home_org};
2768     my $home_prox = $prox_cache{$home_org};
2769     $logger->info("prox cache $home_org " . $prox_cache{$home_org});
2770
2771     my %buckets;
2772     my %hash = map { ($_->to_org => $_->prox) } @$home_prox;
2773     push( @{$buckets{ $hash{$_->{circ_lib}} } }, $_->{id} ) for @$copies;
2774
2775     my @keys = sort { $a <=> $b } keys %buckets;
2776
2777
2778     if( $home_org ne $req_org ) {
2779       # -----------------------------------------------------------------------
2780       # shove the copies close to the request_lib into the primary buckets
2781       # directly before the farthest away copies.  That way, they are not
2782       # given priority, but they are checked before the farthest copies.
2783       # -----------------------------------------------------------------------
2784         $prox_cache{$req_org} =
2785             $e->search_actor_org_unit_proximity({from_org => $req_org})
2786             unless $prox_cache{$req_org};
2787         my $req_prox = $prox_cache{$req_org};
2788
2789         my %buckets2;
2790         my %hash2 = map { ($_->to_org => $_->prox) } @$req_prox;
2791         push( @{$buckets2{ $hash2{$_->{circ_lib}} } }, $_->{id} ) for @$copies;
2792
2793         my $highest_key = $keys[@keys - 1];  # the farthest prox in the exising buckets
2794         my $new_key = $highest_key - 0.5; # right before the farthest prox
2795         my @keys2   = sort { $a <=> $b } keys %buckets2;
2796         for my $key (@keys2) {
2797             last if $key >= $highest_key;
2798             push( @{$buckets{$new_key}}, $_ ) for @{$buckets2{$key}};
2799         }
2800     }
2801
2802     @keys = sort { $a <=> $b } keys %buckets;
2803
2804     my $title;
2805     my %seen;
2806     my @status;
2807     my $age_protect_only = 0;
2808     OUTER: for my $key (@keys) {
2809       my @cps = @{$buckets{$key}};
2810
2811       $logger->info("looking at " . scalar(@{$buckets{$key}}). " copies in proximity bucket $key");
2812
2813       for my $copyid (@cps) {
2814
2815          next if $seen{$copyid};
2816          $seen{$copyid} = 1; # there could be dupes given the merged buckets
2817          my $copy = $e->retrieve_asset_copy($copyid);
2818          $logger->debug("looking at bucket_key=$key, copy $copyid : circ_lib = " . $copy->circ_lib);
2819
2820          unless($title) { # grab the title if we don't already have it
2821             my $vol = $e->retrieve_asset_call_number(
2822                [ $copy->call_number, { flesh => 1, flesh_fields => { bre => ['fixed_fields'], acn => ['record'] } } ] );
2823             $title = $vol->record;
2824          }
2825
2826          @status = verify_copy_for_hold(
2827             $patron, $requestor, $title, $copy, $pickup_lib, $request_lib, $oargs);
2828
2829          $age_protect_only ||= $status[3];
2830          last OUTER if $status[0];
2831       }
2832     }
2833
2834     if (!$status[0]) {
2835         if (!defined($empty_ok)) {
2836             $empty_ok = $e->retrieve_config_global_flag('circ.holds.empty_issuance_ok');
2837             $empty_ok = ($empty_ok and $U->is_true($empty_ok->enabled));
2838         }
2839
2840         return (1,0) if ($empty_ok);
2841     }
2842     $status[3] = $age_protect_only;
2843     return @status;
2844 }
2845
2846 sub _check_monopart_hold_is_possible {
2847     my( $partid, $depth, $request_lib, $patron, $requestor, $pickup_lib, $selection_ou, $oargs ) = @_;
2848
2849     my $e = new_editor();
2850     my %org_filter = create_ranged_org_filter($e, $selection_ou, $depth);
2851
2852     # this monster will grab the id and circ_lib of all of the "holdable" copies for the given record
2853     my $copies = $e->json_query(
2854         {
2855             select => { acp => ['id', 'circ_lib'] },
2856               from => {
2857                 acp => {
2858                     acpm => {
2859                         field  => 'target_copy',
2860                         fkey   => 'id',
2861                         filter => { part => $partid }
2862                     },
2863                     acpl => {
2864                         field => 'id',
2865                         filter => { holdable => 't', deleted => 'f' },
2866                         fkey => 'location'
2867                     },
2868                     ccs  => { field => 'id', filter => { holdable => 't'}, fkey => 'status'   }
2869                 }
2870             },
2871             where => {
2872                 '+acp' => { circulate => 't', deleted => 'f', holdable => 't', %org_filter }
2873             },
2874             distinct => 1
2875         }
2876     );
2877
2878     $logger->info("monopart possible found ".scalar(@$copies)." potential copies");
2879
2880     my $empty_ok;
2881     if (!@$copies) {
2882         $empty_ok = $e->retrieve_config_global_flag('circ.holds.empty_part_ok');
2883         $empty_ok = ($empty_ok and $U->is_true($empty_ok->enabled));
2884
2885         return (
2886             0, 0, [
2887                 new OpenILS::Event(
2888                     "HIGH_LEVEL_HOLD_HAS_NO_COPIES",
2889                     "payload" => {"fail_part" => "no_ultimate_items"}
2890                 )
2891             ]
2892         ) unless $empty_ok;
2893
2894         return (1, 0);
2895     }
2896
2897     # -----------------------------------------------------------------------
2898     # sort the copies into buckets based on their circ_lib proximity to
2899     # the patron's home_ou.
2900     # -----------------------------------------------------------------------
2901
2902     my $home_org = $patron->home_ou;
2903     my $req_org = $request_lib->id;
2904
2905     $prox_cache{$home_org} =
2906         $e->search_actor_org_unit_proximity({from_org => $home_org})
2907         unless $prox_cache{$home_org};
2908     my $home_prox = $prox_cache{$home_org};
2909     $logger->info("prox cache $home_org " . $prox_cache{$home_org});
2910
2911     my %buckets;
2912     my %hash = map { ($_->to_org => $_->prox) } @$home_prox;
2913     push( @{$buckets{ $hash{$_->{circ_lib}} } }, $_->{id} ) for @$copies;
2914
2915     my @keys = sort { $a <=> $b } keys %buckets;
2916
2917
2918     if( $home_org ne $req_org ) {
2919       # -----------------------------------------------------------------------
2920       # shove the copies close to the request_lib into the primary buckets
2921       # directly before the farthest away copies.  That way, they are not
2922       # given priority, but they are checked before the farthest copies.
2923       # -----------------------------------------------------------------------
2924         $prox_cache{$req_org} =
2925             $e->search_actor_org_unit_proximity({from_org => $req_org})
2926             unless $prox_cache{$req_org};
2927         my $req_prox = $prox_cache{$req_org};
2928
2929         my %buckets2;
2930         my %hash2 = map { ($_->to_org => $_->prox) } @$req_prox;
2931         push( @{$buckets2{ $hash2{$_->{circ_lib}} } }, $_->{id} ) for @$copies;
2932
2933         my $highest_key = $keys[@keys - 1];  # the farthest prox in the exising buckets
2934         my $new_key = $highest_key - 0.5; # right before the farthest prox
2935         my @keys2   = sort { $a <=> $b } keys %buckets2;
2936         for my $key (@keys2) {
2937             last if $key >= $highest_key;
2938             push( @{$buckets{$new_key}}, $_ ) for @{$buckets2{$key}};
2939         }
2940     }
2941
2942     @keys = sort { $a <=> $b } keys %buckets;
2943
2944     my $title;
2945     my %seen;
2946     my @status;
2947     my $age_protect_only = 0;
2948     OUTER: for my $key (@keys) {
2949       my @cps = @{$buckets{$key}};
2950
2951       $logger->info("looking at " . scalar(@{$buckets{$key}}). " copies in proximity bucket $key");
2952
2953       for my $copyid (@cps) {
2954
2955          next if $seen{$copyid};
2956          $seen{$copyid} = 1; # there could be dupes given the merged buckets
2957          my $copy = $e->retrieve_asset_copy($copyid);
2958          $logger->debug("looking at bucket_key=$key, copy $copyid : circ_lib = " . $copy->circ_lib);
2959
2960          unless($title) { # grab the title if we don't already have it
2961             my $vol = $e->retrieve_asset_call_number(
2962                [ $copy->call_number, { flesh => 1, flesh_fields => { bre => ['fixed_fields'], acn => ['record'] } } ] );
2963             $title = $vol->record;
2964          }
2965
2966          @status = verify_copy_for_hold(
2967             $patron, $requestor, $title, $copy, $pickup_lib, $request_lib, $oargs);
2968
2969          $age_protect_only ||= $status[3];
2970          last OUTER if $status[0];
2971       }
2972     }
2973
2974     if (!$status[0]) {
2975         if (!defined($empty_ok)) {
2976             $empty_ok = $e->retrieve_config_global_flag('circ.holds.empty_part_ok');
2977             $empty_ok = ($empty_ok and $U->is_true($empty_ok->enabled));
2978         }
2979
2980         return (1,0) if ($empty_ok);
2981     }
2982     $status[3] = $age_protect_only;
2983     return @status;
2984 }
2985
2986
2987 sub _check_volume_hold_is_possible {
2988     my( $vol, $title, $depth, $request_lib, $patron, $requestor, $pickup_lib, $selection_ou, $oargs ) = @_;
2989     my %org_filter = create_ranged_org_filter(new_editor(), $selection_ou, $depth);
2990     my $copies = new_editor->search_asset_copy({call_number => $vol->id, %org_filter});
2991     $logger->info("checking possibility of volume hold for volume ".$vol->id);
2992
2993     my $filter_copies = [];
2994     for my $copy (@$copies) {
2995         # ignore part-mapped copies for regular volume level holds
2996         push(@$filter_copies, $copy) unless
2997             new_editor->search_asset_copy_part_map({target_copy => $copy->id})->[0];
2998     }
2999     $copies = $filter_copies;
3000
3001     return (
3002         0, 0, [
3003             new OpenILS::Event(
3004                 "HIGH_LEVEL_HOLD_HAS_NO_COPIES",
3005                 "payload" => {"fail_part" => "no_ultimate_items"}
3006             )
3007         ]
3008     ) unless @$copies;
3009
3010     my @status;
3011     my $age_protect_only = 0;
3012     for my $copy ( @$copies ) {
3013         @status = verify_copy_for_hold(
3014             $patron, $requestor, $title, $copy, $pickup_lib, $request_lib, $oargs );
3015         $age_protect_only ||= $status[3];
3016         last if $status[0];
3017     }
3018     $status[3] = $age_protect_only;
3019     return @status;
3020 }
3021
3022
3023
3024 sub verify_copy_for_hold {
3025     my( $patron, $requestor, $title, $copy, $pickup_lib, $request_lib, $oargs ) = @_;
3026     # $oargs should be undef unless we're overriding.
3027     $logger->info("checking possibility of copy in hold request for copy ".$copy->id);
3028     my $permitted = OpenILS::Utils::PermitHold::permit_copy_hold(
3029         {
3030             patron           => $patron,
3031             requestor        => $requestor,
3032             copy             => $copy,
3033             title            => $title,
3034             title_descriptor => $title->fixed_fields,
3035             pickup_lib       => $pickup_lib,
3036             request_lib      => $request_lib,
3037             new_hold         => 1,
3038             show_event_list  => 1
3039         }
3040     );
3041
3042     # Check for override permissions on events.
3043     if ($oargs && $permitted && scalar @$permitted) {
3044         # Remove the events from permitted that we can override.
3045         if ($oargs->{events}) {
3046             foreach my $evt (@{$oargs->{events}}) {
3047                 $permitted = [grep {$_->{textcode} ne $evt} @{$permitted}];
3048             }
3049         }
3050         # Now, we handle the override all case by checking remaining
3051         # events against override permissions.
3052         if (scalar @$permitted && $oargs->{all}) {
3053             # Pre-set events and failed members of oargs to empty
3054             # arrays, if they are not set, yet.
3055             $oargs->{events} = [] unless ($oargs->{events});
3056             $oargs->{failed} = [] unless ($oargs->{failed});
3057             # When we're done with these checks, we swap permitted
3058             # with a reference to @disallowed.
3059             my @disallowed = ();
3060             foreach my $evt (@{$permitted}) {
3061                 # Check if we've already seen the event in this
3062                 # session and it failed.
3063                 if (grep {$_ eq $evt->{textcode}} @{$oargs->{failed}}) {
3064                     push(@disallowed, $evt);
3065                 } else {
3066                     # We have to check if the requestor has the
3067                     # override permission.
3068
3069                     # AppUtils::check_user_perms returns the perm if
3070                     # the user doesn't have it, undef if they do.
3071                     if ($apputils->check_user_perms($requestor->id, $requestor->ws_ou, $evt->{textcode} . '.override')) {
3072                         push(@disallowed, $evt);
3073                         push(@{$oargs->{failed}}, $evt->{textcode});
3074                     } else {
3075                         push(@{$oargs->{events}}, $evt->{textcode});
3076                     }
3077                 }
3078             }
3079             $permitted = \@disallowed;
3080         }
3081     }
3082
3083     my $age_protect_only = 0;
3084     if (@$permitted == 1 && @$permitted[0]->{textcode} eq 'ITEM_AGE_PROTECTED') {
3085         $age_protect_only = 1;
3086     }
3087
3088     return (
3089         (not scalar @$permitted), # true if permitted is an empty arrayref
3090         (   # XXX This test is of very dubious value; someone should figure
3091             # out what if anything is checking this value
3092             ($copy->circ_lib == $pickup_lib) and
3093             ($copy->status == OILS_COPY_STATUS_AVAILABLE)
3094         ),
3095         $permitted,
3096         $age_protect_only
3097     );
3098 }
3099
3100
3101
3102 sub find_nearest_permitted_hold {
3103
3104     my $class  = shift;
3105     my $editor = shift;     # CStoreEditor object
3106     my $copy   = shift;     # copy to target
3107     my $user   = shift;     # staff
3108     my $check_only = shift; # do no updates, just see if the copy could fulfill a hold
3109
3110     my $evt = OpenILS::Event->new('ACTION_HOLD_REQUEST_NOT_FOUND');
3111
3112     my $bc = $copy->barcode;
3113
3114     # find any existing holds that already target this copy
3115     my $old_holds = $editor->search_action_hold_request(
3116         {    current_copy => $copy->id,
3117             cancel_time  => undef,
3118             capture_time => undef
3119         }
3120     );
3121
3122     my $hold_stall_interval = $U->ou_ancestor_setting_value($user->ws_ou, OILS_SETTING_HOLD_SOFT_STALL);
3123
3124     $logger->info("circulator: searching for best hold at org ".$user->ws_ou.
3125         " and copy $bc with a hold stalling interval of ". ($hold_stall_interval || "(none)"));
3126
3127     my $fifo = $U->ou_ancestor_setting_value($user->ws_ou, 'circ.holds_fifo');
3128
3129     # the nearest_hold API call now needs this
3130     $copy->call_number($editor->retrieve_asset_call_number($copy->call_number))
3131         unless ref $copy->call_number;
3132
3133     # search for what should be the best holds for this copy to fulfill
3134     my $best_holds = $U->storagereq(
3135         "open-ils.storage.action.hold_request.nearest_hold.atomic", 
3136         $user->ws_ou, $copy, 100, $hold_stall_interval, $fifo );
3137
3138     # Add any pre-targeted holds to the list too? Unless they are already there, anyway.
3139     if ($old_holds) {
3140         for my $holdid (@$old_holds) {
3141             next unless $holdid;
3142             push(@$best_holds, $holdid) unless ( grep { ''.$holdid eq ''.$_ } @$best_holds );
3143         }
3144     }
3145
3146     unless(@$best_holds) {
3147         $logger->info("circulator: no suitable holds found for copy $bc");
3148         return (undef, $evt);
3149     }
3150
3151
3152     my $best_hold;
3153
3154     # for each potential hold, we have to run the permit script
3155     # to make sure the hold is actually permitted.
3156     my %reqr_cache;
3157     my %org_cache;
3158     for my $holdid (@$best_holds) {
3159         next unless $holdid;
3160         $logger->info("circulator: checking if hold $holdid is permitted for copy $bc");
3161
3162         my $hold = $editor->retrieve_action_hold_request($holdid) or next;
3163         # Force and recall holds bypass all rules
3164         if ($hold->hold_type eq 'R' || $hold->hold_type eq 'F') {
3165             $best_hold = $hold;
3166             last;
3167         }
3168         my $reqr = $reqr_cache{$hold->requestor} || $editor->retrieve_actor_user($hold->requestor);
3169         my $rlib = $org_cache{$hold->request_lib} || $editor->retrieve_actor_org_unit($hold->request_lib);
3170
3171         $reqr_cache{$hold->requestor} = $reqr;
3172         $org_cache{$hold->request_lib} = $rlib;
3173
3174         # see if this hold is permitted
3175         my $permitted = OpenILS::Utils::PermitHold::permit_copy_hold(
3176             {
3177                 patron_id   => $hold->usr,
3178                 requestor   => $reqr,
3179                 copy        => $copy,
3180                 pickup_lib  => $hold->pickup_lib,
3181                 request_lib => $rlib,
3182                 retarget    => 1
3183             }
3184         );
3185
3186         if( $permitted ) {
3187             $best_hold = $hold;
3188             last;
3189         }
3190     }
3191
3192
3193     unless( $best_hold ) { # no "good" permitted holds were found
3194         # we got nuthin
3195         $logger->info("circulator: no suitable holds found for copy $bc");
3196         return (undef, $evt);
3197     }
3198
3199     $logger->info("circulator: best hold ".$best_hold->id." found for copy $bc");
3200
3201     # indicate a permitted hold was found
3202     return $best_hold if $check_only;
3203
3204     # we've found a permitted hold.  we need to "grab" the copy
3205     # to prevent re-targeted holds (next part) from re-grabbing the copy
3206     $best_hold->current_copy($copy->id);
3207     $editor->update_action_hold_request($best_hold)
3208         or return (undef, $editor->event);
3209
3210
3211     my @retarget;
3212
3213     # re-target any other holds that already target this copy
3214     for my $old_hold (@$old_holds) {
3215         next if $old_hold->id eq $best_hold->id; # don't re-target the hold we want
3216         $logger->info("circulator: clearing current_copy and prev_check_time on hold ".
3217             $old_hold->id." after a better hold [".$best_hold->id."] was found");
3218         $old_hold->clear_current_copy;
3219         $old_hold->clear_prev_check_time;
3220         $editor->update_action_hold_request($old_hold)
3221             or return (undef, $editor->event);
3222         push(@retarget, $old_hold->id);
3223     }
3224
3225     return ($best_hold, undef, (@retarget) ? \@retarget : undef);
3226 }
3227
3228
3229
3230
3231
3232
3233 __PACKAGE__->register_method(
3234     method   => 'all_rec_holds',
3235     api_name => 'open-ils.circ.holds.retrieve_all_from_title',
3236 );
3237
3238 sub all_rec_holds {
3239     my( $self, $conn, $auth, $title_id, $args ) = @_;
3240
3241     my $e = new_editor(authtoken=>$auth);
3242     $e->checkauth or return $e->event;
3243     $e->allowed('VIEW_HOLD') or return $e->event;
3244
3245     $args ||= {};
3246     $args->{fulfillment_time} = undef; #  we don't want to see old fulfilled holds
3247     $args->{cancel_time} = undef;
3248
3249     my $resp = {
3250           metarecord_holds => []
3251         , title_holds      => []
3252         , volume_holds     => []
3253         , copy_holds       => []
3254         , recall_holds     => []
3255         , force_holds      => []
3256         , part_holds       => []
3257         , issuance_holds   => []
3258     };
3259
3260     my $mr_map = $e->search_metabib_metarecord_source_map({source => $title_id})->[0];
3261     if($mr_map) {
3262         $resp->{metarecord_holds} = $e->search_action_hold_request(
3263             {   hold_type => OILS_HOLD_TYPE_METARECORD,
3264                 target => $mr_map->metarecord,
3265                 %$args
3266             }, {idlist => 1}
3267         );
3268     }
3269
3270     $resp->{title_holds} = $e->search_action_hold_request(
3271         {
3272             hold_type => OILS_HOLD_TYPE_TITLE,
3273             target => $title_id,
3274             %$args
3275         }, {idlist=>1} );
3276
3277     my $parts = $e->search_biblio_monograph_part(
3278         {
3279             record => $title_id
3280         }, {idlist=>1} );
3281
3282     if (@$parts) {
3283         $resp->{part_holds} = $e->search_action_hold_request(
3284             {
3285                 hold_type => OILS_HOLD_TYPE_MONOPART,
3286                 target => $parts,
3287                 %$args
3288             }, {idlist=>1} );
3289     }
3290
3291     my $subs = $e->search_serial_subscription(
3292         { record_entry => $title_id }, {idlist=>1});
3293
3294     if (@$subs) {
3295         my $issuances = $e->search_serial_issuance(
3296             {subscription => $subs}, {idlist=>1}
3297         );
3298
3299         if (@$issuances) {
3300             $resp->{issuance_holds} = $e->search_action_hold_request(
3301                 {
3302                     hold_type => OILS_HOLD_TYPE_ISSUANCE,
3303                     target => $issuances,
3304                     %$args
3305                 }, {idlist=>1}
3306             );
3307         }
3308     }
3309
3310     my $vols = $e->search_asset_call_number(
3311         { record => $title_id, deleted => 'f' }, {idlist=>1});
3312
3313     return $resp unless @$vols;
3314
3315     $resp->{volume_holds} = $e->search_action_hold_request(
3316         {
3317             hold_type => OILS_HOLD_TYPE_VOLUME,
3318             target => $vols,
3319             %$args },
3320         {idlist=>1} );
3321
3322     my $copies = $e->search_asset_copy(
3323         { call_number => $vols, deleted => 'f' }, {idlist=>1});
3324
3325     return $resp unless @$copies;
3326
3327     $resp->{copy_holds} = $e->search_action_hold_request(
3328         {
3329             hold_type => OILS_HOLD_TYPE_COPY,
3330             target => $copies,
3331             %$args },
3332         {idlist=>1} );
3333
3334     $resp->{recall_holds} = $e->search_action_hold_request(
3335         {
3336             hold_type => OILS_HOLD_TYPE_RECALL,
3337             target => $copies,
3338             %$args },
3339         {idlist=>1} );
3340
3341     $resp->{force_holds} = $e->search_action_hold_request(
3342         {
3343             hold_type => OILS_HOLD_TYPE_FORCE,
3344             target => $copies,
3345             %$args },
3346         {idlist=>1} );
3347
3348     return $resp;
3349 }
3350
3351
3352
3353
3354
3355 __PACKAGE__->register_method(
3356     method        => 'uber_hold',
3357     authoritative => 1,
3358     api_name      => 'open-ils.circ.hold.details.retrieve'
3359 );
3360
3361 sub uber_hold {
3362     my($self, $client, $auth, $hold_id, $args) = @_;
3363     my $e = new_editor(authtoken=>$auth);
3364     $e->checkauth or return $e->event;
3365     return uber_hold_impl($e, $hold_id, $args);
3366 }
3367
3368 __PACKAGE__->register_method(
3369     method        => 'batch_uber_hold',
3370     authoritative => 1,
3371     stream        => 1,
3372     api_name      => 'open-ils.circ.hold.details.batch.retrieve'
3373 );
3374
3375 sub batch_uber_hold {
3376     my($self, $client, $auth, $hold_ids, $args) = @_;
3377     my $e = new_editor(authtoken=>$auth);
3378     $e->checkauth or return $e->event;
3379     $client->respond(uber_hold_impl($e, $_, $args)) for @$hold_ids;
3380     return undef;
3381 }
3382
3383 sub uber_hold_impl {
3384     my($e, $hold_id, $args) = @_;
3385     $args ||= {};
3386
3387     my $flesh_fields = ['current_copy', 'usr', 'notes'];
3388     push (@$flesh_fields, 'requestor') if $args->{include_requestor};
3389     push (@$flesh_fields, 'cancel_cause') if $args->{include_cancel_cause};
3390     push (@$flesh_fields, 'sms_carrier') if $args->{include_sms_carrier};
3391
3392     my $hold = $e->retrieve_action_hold_request([
3393         $hold_id,
3394         {flesh => 1, flesh_fields => {ahr => $flesh_fields}}
3395     ]) or return $e->event;
3396
3397     if($hold->usr->id ne $e->requestor->id) {
3398         # caller is asking for someone else's hold
3399         $e->allowed('VIEW_HOLD') or return $e->event;
3400         $hold->notes( # filter out any non-staff ("private") notes (unless marked as public)
3401             [ grep { $U->is_true($_->staff) or $U->is_true($_->pub) } @{$hold->notes} ] );
3402
3403     } else {
3404         # caller is asking for own hold, but may not have permission to view staff notes
3405         unless($e->allowed('VIEW_HOLD')) {
3406             $hold->notes( # filter out any staff notes (unless marked as public)
3407                 [ grep { !$U->is_true($_->staff) or $U->is_true($_->pub) } @{$hold->notes} ] );
3408         }
3409     }
3410
3411     my $user = $hold->usr;
3412     $hold->usr($user->id);
3413
3414
3415     my( $mvr, $volume, $copy, $issuance, $part, $bre ) = find_hold_mvr($e, $hold, $args);
3416
3417     flesh_hold_notices([$hold], $e) unless $args->{suppress_notices};
3418     flesh_hold_transits([$hold]) unless $args->{suppress_transits};
3419
3420     my $details = retrieve_hold_queue_status_impl($e, $hold);
3421     $hold->usr($user) if $args->{include_usr}; # re-flesh
3422
3423     my $resp = {
3424         hold    => $hold,
3425         bre_id  => $bre->id,
3426         ($copy     ? (copy           => $copy)     : ()),
3427         ($volume   ? (volume         => $volume)   : ()),
3428         ($issuance ? (issuance       => $issuance) : ()),
3429         ($part     ? (part           => $part)     : ()),
3430         ($args->{include_bre}  ?  (bre => $bre)    : ()),
3431         ($args->{suppress_mvr} ?  () : (mvr => $mvr)),
3432         %$details
3433     };
3434
3435     $resp->{copy}->location(
3436         $e->retrieve_asset_copy_location($resp->{copy}->location))
3437         if $resp->{copy} and $args->{flesh_acpl};
3438
3439     unless($args->{suppress_patron_details}) {
3440         my $card = $e->retrieve_actor_card($user->card) or return $e->event;
3441         $resp->{patron_first}   = $user->first_given_name,
3442         $resp->{patron_last}    = $user->family_name,
3443         $resp->{patron_barcode} = $card->barcode,
3444         $resp->{patron_alias}   = $user->alias,
3445     };
3446
3447     return $resp;
3448 }
3449
3450
3451
3452 # -----------------------------------------------------
3453 # Returns the MVR object that represents what the
3454 # hold is all about
3455 # -----------------------------------------------------
3456 sub find_hold_mvr {
3457     my( $e, $hold, $args ) = @_;
3458
3459     my $tid;
3460     my $copy;
3461     my $volume;
3462     my $issuance;
3463     my $part;
3464     my $no_mvr = $args->{suppress_mvr};
3465
3466     if( $hold->hold_type eq OILS_HOLD_TYPE_METARECORD ) {
3467         my $mr = $e->retrieve_metabib_metarecord($hold->target)
3468             or return $e->event;
3469         $tid = $mr->master_record;
3470
3471     } elsif( $hold->hold_type eq OILS_HOLD_TYPE_TITLE ) {
3472         $tid = $hold->target;
3473
3474     } elsif( $hold->hold_type eq OILS_HOLD_TYPE_VOLUME ) {
3475         $volume = $e->retrieve_asset_call_number($hold->target)
3476             or return $e->event;
3477         $tid = $volume->record;
3478
3479     } elsif( $hold->hold_type eq OILS_HOLD_TYPE_ISSUANCE ) {
3480         $issuance = $e->retrieve_serial_issuance([
3481             $hold->target,
3482             {flesh => 1, flesh_fields => {siss => [ qw/subscription/ ]}}
3483         ]) or return $e->event;
3484
3485         $tid = $issuance->subscription->record_entry;
3486
3487     } elsif( $hold->hold_type eq OILS_HOLD_TYPE_MONOPART ) {
3488         $part = $e->retrieve_biblio_monograph_part([
3489             $hold->target
3490         ]) or return $e->event;
3491
3492         $tid = $part->record;
3493
3494     } elsif( $hold->hold_type eq OILS_HOLD_TYPE_COPY || $hold->hold_type eq OILS_HOLD_TYPE_RECALL || $hold->hold_type eq OILS_HOLD_TYPE_FORCE ) {
3495         $copy = $e->retrieve_asset_copy([
3496             $hold->target,
3497             {flesh => 1, flesh_fields => {acp => ['call_number']}}
3498         ]) or return $e->event;
3499
3500         $volume = $copy->call_number;
3501         $tid = $volume->record;
3502     }
3503
3504     if(!$copy and ref $hold->current_copy ) {
3505         $copy = $hold->current_copy;
3506         $hold->current_copy($copy->id) unless $args->{include_current_copy};
3507     }
3508
3509     if(!$volume and $copy) {
3510         $volume = $e->retrieve_asset_call_number($copy->call_number);
3511     }
3512
3513     # TODO return metarcord mvr for M holds
3514     my $title = $e->retrieve_biblio_record_entry($tid);
3515     return ( ($no_mvr) ? undef : $U->record_to_mvr($title), $volume, $copy, $issuance, $part, $title );
3516 }
3517
3518 __PACKAGE__->register_method(
3519     method    => 'clear_shelf_cache',
3520     api_name  => 'open-ils.circ.hold.clear_shelf.get_cache',
3521     stream    => 1,
3522     signature => {
3523         desc => q/
3524             Returns the holds processed with the given cache key
3525         /
3526     }
3527 );
3528
3529 sub clear_shelf_cache {
3530     my($self, $client, $auth, $cache_key, $chunk_size) = @_;
3531     my $e = new_editor(authtoken => $auth, xact => 1);
3532     return $e->die_event unless $e->checkauth and $e->allowed('VIEW_HOLD');
3533
3534     $chunk_size ||= 25;
3535     $client->max_chunk_size($chunk_size) if (!$client->can('max_bundle_size') && $client->can('max_chunk_size'));
3536
3537     my $hold_data = OpenSRF::Utils::Cache->new('global')->get_cache($cache_key);
3538
3539     if (!$hold_data) {
3540         $logger->info("no hold data found in cache"); # XXX TODO return event
3541         $e->rollback;
3542         return undef;
3543     }
3544
3545     my $maximum = 0;
3546     foreach (keys %$hold_data) {
3547         $maximum += scalar(@{ $hold_data->{$_} });
3548     }
3549     $client->respond({"maximum" => $maximum, "progress" => 0});
3550
3551     for my $action (sort keys %$hold_data) {
3552         while (@{$hold_data->{$action}}) {
3553             my @hid_chunk = splice @{$hold_data->{$action}}, 0, $chunk_size;
3554
3555             my $result_chunk = $e->json_query({
3556                 "select" => {
3557                     "acp" => ["barcode"],
3558                     "au" => [qw/
3559                         first_given_name second_given_name family_name alias
3560                     /],
3561                     "acn" => ["label"],
3562                     "acnp" => [{column => "label", alias => "prefix"}],
3563                     "acns" => [{column => "label", alias => "suffix"}],
3564                     "bre" => ["marc"],
3565                     "acpl" => ["name"],
3566                     "ahr" => ["id"]
3567                 },
3568                 "from" => {
3569                     "ahr" => {
3570                         "acp" => {
3571                             "field" => "id", "fkey" => "current_copy",
3572                             "join" => {
3573                                 "acn" => {
3574                                     "field" => "id", "fkey" => "call_number",
3575                                     "join" => {
3576                                         "bre" => {
3577                                             "field" => "id", "fkey" => "record"
3578                                         },
3579                                         "acnp" => {
3580                                             "field" => "id", "fkey" => "prefix"
3581                                         },
3582                                         "acns" => {
3583                                             "field" => "id", "fkey" => "suffix"
3584                                         }
3585                                     }
3586                                 },
3587                                 "acpl" => {"field" => "id", "fkey" => "location"}
3588                             }
3589                         },
3590                         "au" => {"field" => "id", "fkey" => "usr"}
3591                     }
3592                 },
3593                 "where" => {"+ahr" => {"id" => \@hid_chunk}}
3594             }, {"substream" => 1}) or return $e->die_event;
3595
3596             $client->respond([
3597                 map {
3598                     +{"action" => $action, "hold_details" => $_}
3599                 } @$result_chunk
3600             ]);
3601         }
3602     }
3603
3604     $e->rollback;
3605     return undef;
3606 }
3607
3608
3609 __PACKAGE__->register_method(
3610     method    => 'clear_shelf_process',
3611     stream    => 1,
3612     api_name  => 'open-ils.circ.hold.clear_shelf.process',
3613     signature => {
3614         desc => q/
3615             1. Find all holds that have expired on the holds shelf
3616             2. Cancel the holds
3617             3. If a clear-shelf status is configured, put targeted copies into this status
3618             4. Divide copies into 3 groups: items to transit, items to reshelve, and items
3619                 that are needed for holds.  No subsequent action is taken on the holds
3620                 or items after grouping.
3621         /
3622     }
3623 );
3624
3625 sub clear_shelf_process {
3626     my($self, $client, $auth, $org_id, $match_copy, $chunk_size) = @_;
3627
3628     my $e = new_editor(authtoken=>$auth);
3629     $e->checkauth or return $e->die_event;
3630     my $cache = OpenSRF::Utils::Cache->new('global');
3631
3632     $org_id ||= $e->requestor->ws_ou;
3633     $e->allowed('UPDATE_HOLD', $org_id) or return $e->die_event;
3634
3635     my $copy_status = $U->ou_ancestor_setting_value($org_id, 'circ.holds.clear_shelf.copy_status');
3636
3637     my @hold_ids = $self->method_lookup(
3638         "open-ils.circ.captured_holds.id_list.expired_on_shelf.retrieve"
3639     )->run($auth, $org_id, $match_copy);
3640
3641     $e->xact_begin;
3642
3643     my @holds;
3644     my @canceled_holds; # newly canceled holds
3645     $chunk_size ||= 25; # chunked status updates
3646     $client->max_chunk_size($chunk_size) if (!$client->can('max_bundle_size') && $client->can('max_chunk_size'));
3647
3648     my $counter = 0;
3649     for my $hold_id (@hold_ids) {
3650
3651         $logger->info("Clear shelf processing hold $hold_id");
3652
3653         my $hold = $e->retrieve_action_hold_request([
3654             $hold_id, {
3655                 flesh => 1,
3656                 flesh_fields => {ahr => ['current_copy']}
3657             }
3658         ]);
3659
3660         if (!$hold->cancel_time) { # may be canceled but still on the holds shelf
3661             $hold->cancel_time('now');
3662             $hold->cancel_cause(2); # Hold Shelf expiration
3663             $e->update_action_hold_request($hold) or return $e->die_event;
3664             push(@canceled_holds, $hold_id);
3665         }
3666
3667         my $copy = $hold->current_copy;
3668
3669         if($copy_status or $copy_status == 0) {
3670             # if a clear-shelf copy status is defined, update the copy
3671             $copy->status($copy_status);
3672             $copy->edit_date('now');
3673             $copy->editor($e->requestor->id);
3674             $e->update_asset_copy($copy) or return $e->die_event;
3675         }
3676
3677         push(@holds, $hold);
3678         $client->respond({maximum => scalar(@holds), progress => $counter}) if ( (++$counter % $chunk_size) == 0);
3679     }
3680
3681     if ($e->commit) {
3682
3683         my %cache_data = (
3684             hold => [],
3685             transit => [],
3686             shelf => [],
3687             pl_changed => pickup_lib_changed_on_shelf_holds($e, $org_id, \@hold_ids)
3688         );
3689
3690         for my $hold (@holds) {
3691
3692             my $copy = $hold->current_copy;
3693             my ($alt_hold) = __PACKAGE__->find_nearest_permitted_hold($e, $copy, $e->requestor, 1);
3694
3695             if($alt_hold and !$match_copy) {
3696
3697                 push(@{$cache_data{hold}}, $hold->id); # copy is needed for a hold
3698
3699             } elsif($copy->circ_lib != $e->requestor->ws_ou) {
3700
3701                 push(@{$cache_data{transit}}, $hold->id); # copy needs to transit
3702
3703             } else {
3704
3705                 push(@{$cache_data{shelf}}, $hold->id); # copy needs to go back to the shelf
3706             }
3707         }
3708
3709         my $cache_key = md5_hex(time . $$ . rand());
3710         $logger->info("clear_shelf_cache: storing under $cache_key");
3711         $cache->put_cache($cache_key, \%cache_data, 7200); # TODO: 2 hours.  configurable?
3712
3713         # tell the client we're done
3714         $client->respond_complete({cache_key => $cache_key});
3715
3716         # ------------
3717         # fire off the hold cancelation trigger and wait for response so don't flood the service
3718
3719         # refetch the holds to pick up the caclulated cancel_time,
3720         # which may be needed by Action/Trigger
3721         $e->xact_begin;
3722         my $updated_holds = [];
3723         $updated_holds = $e->search_action_hold_request({id => \@canceled_holds}, {substream => 1}) if (@canceled_holds > 0);
3724         $e->rollback;
3725
3726         $U->create_events_for_hook(
3727             'hold_request.cancel.expire_holds_shelf',
3728             $_, $org_id, undef, undef, 1) for @$updated_holds;
3729
3730     } else {
3731         # tell the client we're done
3732         $client->respond_complete;
3733     }
3734 }
3735
3736 # returns IDs for holds that are on the holds shelf but 
3737 # have had their pickup_libs change while on the shelf.
3738 sub pickup_lib_changed_on_shelf_holds {
3739     my $e = shift;
3740     my $org_id = shift;
3741     my $ignore_holds = shift;
3742     $ignore_holds = [$ignore_holds] if !ref($ignore_holds);
3743
3744     my $query = {
3745         select => { alhr => ['id'] },
3746         from   => {
3747             alhr => {
3748                 acp => {
3749                     field => 'id',
3750                     fkey  => 'current_copy'
3751                 },
3752             }
3753         },
3754         where => {
3755             '+acp' => { status => OILS_COPY_STATUS_ON_HOLDS_SHELF },
3756             '+alhr' => {
3757                 capture_time     => { "!=" => undef },
3758                 fulfillment_time => undef,
3759                 current_shelf_lib => $org_id,
3760                 pickup_lib => {'!='  => {'+alhr' => 'current_shelf_lib'}}
3761             }
3762         }
3763     };
3764
3765     $query->{where}->{'+alhr'}->{id} =
3766         {'not in' => $ignore_holds} if @$ignore_holds;
3767
3768     my $hold_ids = $e->json_query($query);
3769     return [ map { $_->{id} } @$hold_ids ];
3770 }
3771
3772 __PACKAGE__->register_method(
3773     method    => 'usr_hold_summary',
3774     api_name  => 'open-ils.circ.holds.user_summary',
3775     signature => q/
3776         Returns a summary of holds statuses for a given user
3777     /
3778 );
3779
3780 sub usr_hold_summary {
3781     my($self, $conn, $auth, $user_id) = @_;
3782
3783     my $e = new_editor(authtoken=>$auth);
3784     $e->checkauth or return $e->event;
3785     $e->allowed('VIEW_HOLD') or return $e->event;
3786
3787     my $holds = $e->search_action_hold_request(
3788         {
3789             usr =>  $user_id ,
3790             fulfillment_time => undef,
3791             cancel_time      => undef,
3792         }
3793     );
3794
3795     my %summary = (1 => 0, 2 => 0, 3 => 0, 4 => 0);
3796     $summary{_hold_status($e, $_)} += 1 for @$holds;
3797     return \%summary;
3798 }
3799
3800
3801
3802 __PACKAGE__->register_method(
3803     method    => 'hold_has_copy_at',
3804     api_name  => 'open-ils.circ.hold.has_copy_at',
3805     signature => {
3806         desc   =>
3807                 'Returns the ID of the found copy and name of the shelving location if there is ' .
3808                 'an available copy at the specified org unit.  Returns empty hash otherwise.  '   .
3809                 'The anticipated use for this method is to determine whether an item is '         .
3810                 'available at the library where the user is placing the hold (or, alternatively, '.
3811                 'at the pickup library) to encourage bypassing the hold placement and just '      .
3812                 'checking out the item.' ,
3813         params => [
3814             { desc => 'Authentication Token', type => 'string' },
3815             { desc => 'Method Arguments.  Options include: hold_type, hold_target, org_unit.  '
3816                     . 'hold_type is the hold type code (T, V, C, M, ...).  '
3817                     . 'hold_target is the identifier of the hold target object.  '
3818                     . 'org_unit is org unit ID.',
3819               type => 'object'
3820             }
3821         ],
3822         return => {
3823             desc => q/Result hash like { "copy" : copy_id, "location" : location_name }, empty hash on misses, event on error./,
3824             type => 'object'
3825         }
3826     }
3827 );
3828
3829 sub hold_has_copy_at {
3830     my($self, $conn, $auth, $args) = @_;
3831
3832     my $e = new_editor(authtoken=>$auth);
3833     $e->checkauth or return $e->event;
3834
3835     my $hold_type   = $$args{hold_type};
3836     my $hold_target = $$args{hold_target};
3837     my $org_unit    = $$args{org_unit};
3838
3839     my $query = {
3840         select => {acp => ['id'], acpl => ['name']},
3841         from   => {
3842             acp => {
3843                 acpl => {
3844                     field => 'id',
3845                     filter => { holdable => 't', deleted => 'f' },
3846                     fkey => 'location'
3847                 },
3848                 ccs  => {field => 'id', filter => { holdable => 't'}, fkey => 'status'  }
3849             }
3850         },
3851         where => {'+acp' => { circulate => 't', deleted => 'f', holdable => 't', circ_lib => $org_unit, status => [0,7]}},
3852         limit => 1
3853     };
3854
3855     if($hold_type eq 'C' or $hold_type eq 'F' or $hold_type eq 'R') {
3856
3857         $query->{where}->{'+acp'}->{id} = $hold_target;
3858
3859     } elsif($hold_type eq 'V') {
3860
3861         $query->{where}->{'+acp'}->{call_number} = $hold_target;
3862
3863     } elsif($hold_type eq 'P') {
3864
3865         $query->{from}->{acp}->{acpm} = {
3866             field  => 'target_copy',
3867             fkey   => 'id',
3868             filter => {part => $hold_target},
3869         };
3870
3871     } elsif($hold_type eq 'I') {
3872
3873         $query->{from}->{acp}->{sitem} = {
3874             field  => 'unit',
3875             fkey   => 'id',
3876             filter => {issuance => $hold_target},
3877         };
3878
3879     } elsif($hold_type eq 'T') {
3880
3881         $query->{from}->{acp}->{acn} = {
3882             field  => 'id',
3883             fkey   => 'call_number',
3884             'join' => {
3885                 bre => {
3886                     field  => 'id',
3887                     filter => {id => $hold_target},
3888                     fkey   => 'record'
3889                 }
3890             }
3891         };
3892
3893     } else {
3894
3895         $query->{from}->{acp}->{acn} = {
3896             field => 'id',
3897             fkey  => 'call_number',
3898             join  => {
3899                 bre => {
3900                     field => 'id',
3901                     fkey  => 'record',
3902                     join  => {
3903                         mmrsm => {
3904                             field  => 'source',
3905                             fkey   => 'id',
3906                             filter => {metarecord => $hold_target},
3907                         }
3908                     }
3909                 }
3910             }
3911         };
3912     }
3913
3914     my $res = $e->json_query($query)->[0] or return {};
3915     return {copy => $res->{id}, location => $res->{name}} if $res;
3916 }
3917
3918
3919 # returns true if the user already has an item checked out
3920 # that could be used to fulfill the requested hold.
3921 sub hold_item_is_checked_out {
3922     my($e, $user_id, $hold_type, $hold_target) = @_;
3923
3924     my $query = {
3925         select => {acp => ['id']},
3926         from   => {acp => {}},
3927         where  => {
3928             '+acp' => {
3929                 id => {
3930                     in => { # copies for circs the user has checked out
3931                         select => {circ => ['target_copy']},
3932                         from   => 'circ',
3933                         where  => {
3934                             usr => $user_id,
3935                             checkin_time => undef,
3936                             '-or' => [
3937                                 {stop_fines => ["MAXFINES","LONGOVERDUE"]},
3938                                 {stop_fines => undef}
3939                             ],
3940                         }
3941                     }
3942                 }
3943             }
3944         },
3945         limit => 1
3946     };
3947
3948     if($hold_type eq 'C' || $hold_type eq 'R' || $hold_type eq 'F') {
3949
3950         $query->{where}->{'+acp'}->{id}->{in}->{where}->{'target_copy'} = $hold_target;
3951
3952     } elsif($hold_type eq 'V') {
3953
3954         $query->{where}->{'+acp'}->{call_number} = $hold_target;
3955
3956      } elsif($hold_type eq 'P') {
3957
3958         $query->{from}->{acp}->{acpm} = {
3959             field  => 'target_copy',
3960             fkey   => 'id',
3961             filter => {part => $hold_target},
3962         };
3963
3964      } elsif($hold_type eq 'I') {
3965
3966         $query->{from}->{acp}->{sitem} = {
3967             field  => 'unit',
3968             fkey   => 'id',
3969             filter => {issuance => $hold_target},
3970         };
3971
3972     } elsif($hold_type eq 'T') {
3973
3974         $query->{from}->{acp}->{acn} = {
3975             field  => 'id',
3976             fkey   => 'call_number',
3977             'join' => {
3978                 bre => {
3979                     field  => 'id',
3980                     filter => {id => $hold_target},
3981                     fkey   => 'record'
3982                 }
3983             }
3984         };
3985
3986     } else {
3987
3988         $query->{from}->{acp}->{acn} = {
3989             field => 'id',
3990             fkey => 'call_number',
3991             join => {
3992                 bre => {
3993                     field => 'id',
3994                     fkey => 'record',
3995                     join => {
3996                         mmrsm => {
3997                             field => 'source',
3998                             fkey => 'id',
3999                             filter => {metarecord => $hold_target},
4000                         }
4001                     }
4002                 }
4003             }
4004         };
4005     }
4006
4007     return $e->json_query($query)->[0];
4008 }
4009
4010 __PACKAGE__->register_method(
4011     method    => 'change_hold_title',
4012     api_name  => 'open-ils.circ.hold.change_title',
4013     signature => {
4014         desc => q/
4015             Updates all title level holds targeting the specified bibs to point a new bib./,
4016         params => [
4017             { desc => 'Authentication Token', type => 'string' },
4018             { desc => 'New Target Bib Id',    type => 'number' },
4019             { desc => 'Old Target Bib Ids',   type => 'array'  },
4020         ],
4021         return => { desc => '1 on success' }
4022     }
4023 );
4024
4025 __PACKAGE__->register_method(
4026     method    => 'change_hold_title_for_specific_holds',
4027     api_name  => 'open-ils.circ.hold.change_title.specific_holds',
4028     signature => {
4029         desc => q/
4030             Updates specified holds to target new bib./,
4031         params => [
4032             { desc => 'Authentication Token', type => 'string' },
4033             { desc => 'New Target Bib Id',    type => 'number' },
4034             { desc => 'Holds Ids for holds to update',   type => 'array'  },
4035         ],
4036         return => { desc => '1 on success' }
4037     }
4038 );
4039
4040
4041 sub change_hold_title {
4042     my( $self, $client, $auth, $new_bib_id, $bib_ids ) = @_;
4043
4044     my $e = new_editor(authtoken=>$auth, xact=>1);
4045     return $e->die_event unless $e->checkauth;
4046
4047     my $holds = $e->search_action_hold_request(
4048         [
4049             {
4050                 capture_time     => undef,
4051                 cancel_time      => undef,
4052                 fulfillment_time => undef,
4053                 hold_type        => 'T',
4054                 target           => $bib_ids
4055             },
4056             {
4057                 flesh        => 1,
4058                 flesh_fields => { ahr => ['usr'] }
4059             }
4060         ],
4061         { substream => 1 }
4062     );
4063
4064     for my $hold (@$holds) {
4065         $e->allowed('UPDATE_HOLD', $hold->usr->home_ou) or return $e->die_event;
4066         $logger->info("Changing hold " . $hold->id . " target from " . $hold->target . " to $new_bib_id in title hold target change");
4067         $hold->target( $new_bib_id );
4068         $e->update_action_hold_request($hold) or return $e->die_event;
4069     }
4070
4071     $e->commit;
4072
4073     _reset_hold($self, $e->requestor, $_) for @$holds;
4074
4075     return 1;
4076 }
4077
4078 sub change_hold_title_for_specific_holds {
4079     my( $self, $client, $auth, $new_bib_id, $hold_ids ) = @_;
4080
4081     my $e = new_editor(authtoken=>$auth, xact=>1);
4082     return $e->die_event unless $e->checkauth;
4083
4084     my $holds = $e->search_action_hold_request(
4085         [
4086             {
4087                 capture_time     => undef,
4088                 cancel_time      => undef,
4089                 fulfillment_time => undef,
4090                 hold_type        => 'T',
4091                 id               => $hold_ids
4092             },
4093             {
4094                 flesh        => 1,
4095                 flesh_fields => { ahr => ['usr'] }
4096             }
4097         ],
4098         { substream => 1 }
4099     );
4100
4101     for my $hold (@$holds) {
4102         $e->allowed('UPDATE_HOLD', $hold->usr->home_ou) or return $e->die_event;
4103         $logger->info("Changing hold " . $hold->id . " target from " . $hold->target . " to $new_bib_id in title hold target change");
4104         $hold->target( $new_bib_id );
4105         $e->update_action_hold_request($hold) or return $e->die_event;
4106     }
4107
4108     $e->commit;
4109
4110     _reset_hold($self, $e->requestor, $_) for @$holds;
4111
4112     return 1;
4113 }
4114
4115 __PACKAGE__->register_method(
4116     method    => 'rec_hold_count',
4117     api_name  => 'open-ils.circ.bre.holds.count',
4118     signature => {
4119         desc => q/Returns the total number of holds that target the
4120             selected bib record or its associated copies and call_numbers/,
4121         params => [
4122             { desc => 'Bib ID', type => 'number' },
4123             { desc => q/Optional arguments.  Supported arguments include:
4124                 "pickup_lib_descendant" -> limit holds to those whose pickup
4125                 library is equal to or is a child of the provided org unit/,
4126                 type => 'object'
4127             }
4128         ],
4129         return => {desc => 'Hold count', type => 'number'}
4130     }
4131 );
4132
4133 __PACKAGE__->register_method(
4134     method    => 'rec_hold_count',
4135     api_name  => 'open-ils.circ.mmr.holds.count',
4136     signature => {
4137         desc => q/Returns the total number of holds that target the
4138             selected metarecord or its associated copies, call_numbers, and bib records/,
4139         params => [
4140             { desc => 'Metarecord ID', type => 'number' },
4141         ],
4142         return => {desc => 'Hold count', type => 'number'}
4143     }
4144 );
4145
4146 # XXX Need to add type I holds to these counts
4147 sub rec_hold_count {
4148     my($self, $conn, $target_id, $args) = @_;
4149     $args ||= {};
4150
4151     my $mmr_join = {
4152         mmrsm => {
4153             field => 'source',
4154             fkey => 'id',
4155             filter => {metarecord => $target_id}
4156         }
4157     };
4158
4159     my $bre_join = {
4160         bre => {
4161             field => 'id',
4162             filter => { id => $target_id },
4163             fkey => 'record'
4164         }
4165     };
4166
4167     if($self->api_name =~ /mmr/) {
4168         delete $bre_join->{bre}->{filter};
4169         $bre_join->{bre}->{join} = $mmr_join;
4170     }
4171
4172     my $cn_join = {
4173         acn => {
4174             field => 'id',
4175             fkey => 'call_number',
4176             join => $bre_join
4177         }
4178     };
4179
4180     my $query = {
4181         select => {ahr => [{column => 'id', transform => 'count', alias => 'count'}]},
4182         from => 'ahr',
4183         where => {
4184             '+ahr' => {
4185                 cancel_time => undef,
4186                 fulfillment_time => undef,
4187                 '-or' => [
4188                     {
4189                         '-and' => {
4190                             hold_type => [qw/C F R/],
4191                             target => {
4192                                 in => {
4193                                     select => {acp => ['id']},
4194                                     from => { acp => $cn_join }
4195                                 }
4196                             }
4197                         }
4198                     },
4199                     {
4200                         '-and' => {
4201                             hold_type => 'V',
4202                             target => {
4203                                 in => {
4204                                     select => {acn => ['id']},
4205                                     from => {acn => $bre_join}
4206                                 }
4207                             }
4208                         }
4209                     },
4210                     {
4211                         '-and' => {
4212                             hold_type => 'P',
4213                             target => {
4214                                 in => {
4215                                     select => {bmp => ['id']},
4216                                     from => {bmp => $bre_join}
4217                                 }
4218                             }
4219                         }
4220                     },
4221                     {
4222                         '-and' => {
4223                             hold_type => 'T',
4224                             target => $target_id
4225                         }
4226                     }
4227                 ]
4228             }
4229         }
4230     };
4231
4232     if($self->api_name =~ /mmr/) {
4233         $query->{where}->{'+ahr'}->{'-or'}->[3] = {
4234             '-and' => {
4235                 hold_type => 'T',
4236                 target => {
4237                     in => {
4238                         select => {bre => ['id']},
4239                         from => {bre => $mmr_join}
4240                     }
4241                 }
4242             }
4243         };
4244
4245         $query->{where}->{'+ahr'}->{'-or'}->[4] = {
4246             '-and' => {
4247                 hold_type => 'M',
4248                 target => $target_id
4249             }
4250         };
4251     }
4252
4253
4254     if (my $pld = $args->{pickup_lib_descendant}) {
4255
4256         my $top_ou = new_editor()->search_actor_org_unit(
4257             {parent_ou => undef}
4258         )->[0]; # XXX Assumes single root node. Not alone in this...
4259
4260         $query->{where}->{'+ahr'}->{pickup_lib} = {
4261             in => {
4262                 select  => {aou => [{ 
4263                     column => 'id', 
4264                     transform => 'actor.org_unit_descendants', 
4265                     result_field => 'id' 
4266                 }]},
4267                 from    => 'aou',
4268                 where   => {id => $pld}
4269             }
4270         } if ($pld != $top_ou->id);
4271     }
4272
4273
4274     return new_editor()->json_query($query)->[0]->{count};
4275 }
4276
4277 # A helper function to calculate a hold's expiration time at a given
4278 # org_unit. Takes the org_unit as an argument and returns either the
4279 # hold expire time as an ISO8601 string or undef if there is no hold
4280 # expiration interval set for the subject ou.
4281 sub calculate_expire_time
4282 {
4283     my $ou = shift;
4284     my $interval = $U->ou_ancestor_setting_value($ou, OILS_SETTING_HOLD_EXPIRE);
4285     if($interval) {
4286         my $date = DateTime->now->add(seconds => OpenILS::Utils::DateTime->interval_to_seconds($interval));
4287         return $U->epoch2ISO8601($date->epoch);
4288     }
4289     return undef;
4290 }
4291
4292
4293 __PACKAGE__->register_method(
4294     method    => 'mr_hold_filter_attrs',
4295     api_name  => 'open-ils.circ.mmr.holds.filters',
4296     authoritative => 1,
4297     stream => 1,
4298     signature => {
4299         desc => q/
4300             Returns the set of available formats and languages for the
4301             constituent records of the provided metarcord.
4302             If an array of hold IDs is also provided, information about
4303             each is returned as well.  This information includes:
4304             1. a slightly easier to read version of holdable_formats
4305             2. attributes describing the set of format icons included
4306                in the set of desired, constituent records.
4307         /,
4308         params => [
4309             {desc => 'Metarecord ID', type => 'number'},
4310             {desc => 'Context Org ID', type => 'number'},
4311             {desc => 'Hold ID List', type => 'array'},
4312         ],
4313         return => {
4314             desc => q/
4315                 Stream of objects.  The first will have a 'metarecord' key
4316                 containing non-hold-specific metarecord information, subsequent
4317                 responses will contain a 'hold' key containing hold-specific
4318                 information
4319             /, 
4320             type => 'object'
4321         }
4322     }
4323 );
4324
4325 sub mr_hold_filter_attrs { 
4326     my ($self, $client, $mr_id, $org_id, $hold_ids) = @_;
4327     my $e = new_editor();
4328
4329     # by default, return MR / hold attributes for all constituent
4330     # records with holdable copies.  If there is a hard boundary,
4331     # though, limit to records with copies within the boundary,
4332     # since anything outside the boundary can never be held.
4333     my $org_depth = 0;
4334     if ($org_id) {
4335         $org_depth = $U->ou_ancestor_setting_value(
4336             $org_id, OILS_SETTING_HOLD_HARD_BOUNDARY) || 0;
4337     }
4338
4339     # get all org-scoped records w/ holdable copies for this metarecord
4340     my ($bre_ids) = $self->method_lookup(
4341         'open-ils.circ.holds.metarecord.filtered_records')->run(
4342             $mr_id, undef, $org_id, $org_depth);
4343
4344     my $item_lang_attr = 'item_lang'; # configurable?
4345     my $format_attr = $e->retrieve_config_global_flag(
4346         'opac.metarecord.holds.format_attr')->value;
4347
4348     # helper sub for fetching ccvms for a batch of record IDs
4349     sub get_batch_ccvms {
4350         my ($e, $attr, $bre_ids) = @_;
4351         return [] unless $bre_ids and @$bre_ids;
4352         my $vals = $e->search_metabib_record_attr_flat({
4353             attr => $attr,
4354             id => $bre_ids
4355         });
4356         return [] unless @$vals;
4357         return $e->search_config_coded_value_map({
4358             ctype => $attr,
4359             code => [map {$_->value} @$vals]
4360         });
4361     }
4362
4363     my $langs = get_batch_ccvms($e, $item_lang_attr, $bre_ids);
4364     my $formats = get_batch_ccvms($e, $format_attr, $bre_ids);
4365
4366     $client->respond({
4367         metarecord => {
4368             id => $mr_id,
4369             formats => $formats,
4370             langs => $langs
4371         }
4372     });
4373
4374     return unless $hold_ids;
4375     my $icon_attr = $e->retrieve_config_global_flag('opac.icon_attr');
4376     $icon_attr = $icon_attr ? $icon_attr->value : '';
4377
4378     for my $hold_id (@$hold_ids) {
4379         my $hold = $e->retrieve_action_hold_request($hold_id) 
4380             or return $e->event;
4381
4382         next unless $hold->hold_type eq 'M';
4383
4384         my $resp = {
4385             hold => {
4386                 id => $hold_id,
4387                 formats => [],
4388                 langs => []
4389             }
4390         };
4391
4392         # collect the ccvm's for the selected formats / language
4393         # (i.e. the holdable formats) on the MR.
4394         # this assumes a two-key structure for format / language,
4395         # though no assumption is made about the keys themselves.
4396         my $hformats = OpenSRF::Utils::JSON->JSON2perl($hold->holdable_formats);
4397         my $lang_vals = [];
4398         my $format_vals = [];
4399         for my $val (values %$hformats) {
4400             # val is either a single ccvm or an array of them
4401             $val = [$val] unless ref $val eq 'ARRAY';
4402             for my $node (@$val) {
4403                 push (@$lang_vals, $node->{_val})   
4404                     if $node->{_attr} eq $item_lang_attr; 
4405                 push (@$format_vals, $node->{_val})   
4406                     if $node->{_attr} eq $format_attr;
4407             }
4408         }
4409
4410         # fetch the ccvm's for consistency with the {metarecord} blob
4411         $resp->{hold}{formats} = $e->search_config_coded_value_map({
4412             ctype => $format_attr, code => $format_vals});
4413         $resp->{hold}{langs} = $e->search_config_coded_value_map({
4414             ctype => $item_lang_attr, code => $lang_vals});
4415
4416         # find all of the bib records within this metarcord whose 
4417         # format / language match the holdable formats on the hold
4418         my ($bre_ids) = $self->method_lookup(
4419             'open-ils.circ.holds.metarecord.filtered_records')->run(
4420                 $hold->target, $hold->holdable_formats, 
4421                 $hold->selection_ou, $hold->selection_depth);
4422
4423         # now find all of the 'icon' attributes for the records
4424         $resp->{hold}{icons} = get_batch_ccvms($e, $icon_attr, $bre_ids);
4425         $client->respond($resp);
4426     }
4427
4428     return;
4429 }
4430
4431 __PACKAGE__->register_method(
4432     method        => "copy_has_holds_count",
4433     api_name      => "open-ils.circ.copy.has_holds_count",
4434     authoritative => 1,
4435     signature     => {
4436         desc => q/
4437             Returns the number of holds a paticular copy has
4438         /,
4439         params => [
4440             { desc => 'Authentication Token', type => 'string'},
4441             { desc => 'Copy ID', type => 'number'}
4442         ],
4443         return => {
4444             desc => q/
4445                 Simple count value
4446             /,
4447             type => 'number'
4448         }
4449     }
4450 );
4451
4452 sub copy_has_holds_count {
4453     my( $self, $conn, $auth, $copyid ) = @_;
4454     my $e = new_editor(authtoken=>$auth);
4455     return $e->event unless $e->checkauth;
4456
4457     if( $copyid && $copyid > 0 ) {
4458         my $meth = 'retrieve_action_has_holds_count';
4459         my $data = $e->$meth($copyid);
4460         if($data){
4461                 return $data->count();
4462         }
4463     }
4464     return 0;
4465 }
4466
4467 1;