8ef501b2613d97fdb497d23fe41a12db14275e85
[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 OpenSRF::Utils qw/:datetime/;
37 use Digest::MD5 qw(md5_hex);
38 use OpenSRF::Utils::Cache;
39 my $apputils = "OpenILS::Application::AppUtils";
40 my $U = $apputils;
41
42 __PACKAGE__->register_method(
43     method    => "test_and_create_hold_batch",
44     api_name  => "open-ils.circ.holds.test_and_create.batch",
45     stream => 1,
46     signature => {
47         desc => q/This is for batch creating a set of holds where every field is identical except for the targets./,
48         params => [
49             { desc => 'Authentication token', type => 'string' },
50             { 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'},
51             { desc => 'Array of target ids', type => 'array' }
52         ],
53         return => {
54             desc => 'Array of hold ID on success, -1 on missing arg, event (or ref to array of events) on error(s)',
55         },
56     }
57 );
58
59 __PACKAGE__->register_method(
60     method    => "test_and_create_hold_batch",
61     api_name  => "open-ils.circ.holds.test_and_create.batch.override",
62     stream => 1,
63     signature => {
64         desc  => '@see open-ils.circ.holds.test_and_create.batch',
65     }
66 );
67
68
69 sub test_and_create_hold_batch {
70     my( $self, $conn, $auth, $params, $target_list, $oargs ) = @_;
71
72     my $override = 0;
73     if ($self->api_name =~ /override/) {
74         $override = 1;
75         $oargs = { all => 1 } unless defined $oargs;
76         $$params{oargs} = $oargs; # for is_possible checking.
77     }
78
79     my $e = new_editor(authtoken=>$auth);
80     return $e->die_event unless $e->checkauth;
81     $$params{'requestor'} = $e->requestor->id;
82
83     my $target_field;
84     if ($$params{'hold_type'} eq 'T') { $target_field = 'titleid'; }
85     elsif ($$params{'hold_type'} eq 'C') { $target_field = 'copy_id'; }
86     elsif ($$params{'hold_type'} eq 'R') { $target_field = 'copy_id'; }
87     elsif ($$params{'hold_type'} eq 'F') { $target_field = 'copy_id'; }
88     elsif ($$params{'hold_type'} eq 'I') { $target_field = 'issuanceid'; }
89     elsif ($$params{'hold_type'} eq 'V') { $target_field = 'volume_id'; }
90     elsif ($$params{'hold_type'} eq 'M') { $target_field = 'mrid'; }
91     elsif ($$params{'hold_type'} eq 'P') { $target_field = 'partid'; }
92     else { return undef; }
93
94     foreach (@$target_list) {
95         $$params{$target_field} = $_;
96         my $res;
97         ($res) = $self->method_lookup(
98             'open-ils.circ.title_hold.is_possible')->run($auth, $params, $override ? $oargs : {});
99         if ($res->{'success'} == 1) {
100
101             $params->{'depth'} = $res->{'depth'} if $res->{'depth'};
102
103             # Remove oargs from params so holds can be created.
104             if ($$params{oargs}) {
105                 delete $$params{oargs};
106             }
107
108             my $ahr = construct_hold_request_object($params);
109             my ($res2) = $self->method_lookup(
110                 $override
111                 ? 'open-ils.circ.holds.create.override'
112                 : 'open-ils.circ.holds.create'
113             )->run($auth, $ahr, $oargs);
114             $res2 = {
115                 'target' => $$params{$target_field},
116                 'result' => $res2
117             };
118             $conn->respond($res2);
119         } else {
120             $res = {
121                 'target' => $$params{$target_field},
122                 'result' => $res
123             };
124             $conn->respond($res);
125         }
126     }
127     return undef;
128 }
129
130 sub construct_hold_request_object {
131     my ($params) = @_;
132
133     my $ahr = Fieldmapper::action::hold_request->new;
134     $ahr->isnew('1');
135
136     foreach my $field (keys %{ $params }) {
137         if ($field eq 'depth') { $ahr->selection_depth($$params{$field}); }
138         elsif ($field eq 'patronid') {
139             $ahr->usr($$params{$field}); }
140         elsif ($field eq 'titleid') { $ahr->target($$params{$field}); }
141         elsif ($field eq 'copy_id') { $ahr->target($$params{$field}); }
142         elsif ($field eq 'issuanceid') { $ahr->target($$params{$field}); }
143         elsif ($field eq 'volume_id') { $ahr->target($$params{$field}); }
144         elsif ($field eq 'mrid') { $ahr->target($$params{$field}); }
145         elsif ($field eq 'partid') { $ahr->target($$params{$field}); }
146         else {
147             $ahr->$field($$params{$field});
148         }
149     }
150     return $ahr;
151 }
152
153 __PACKAGE__->register_method(
154     method    => "create_hold_batch",
155     api_name  => "open-ils.circ.holds.create.batch",
156     stream => 1,
157     signature => {
158         desc => q/@see open-ils.circ.holds.create.batch/,
159         params => [
160             { desc => 'Authentication token', type => 'string' },
161             { desc => 'Array of hold objects', type => 'array' }
162         ],
163         return => {
164             desc => 'Array of hold ID on success, -1 on missing arg, event (or ref to array of events) on error(s)',
165         },
166     }
167 );
168
169 __PACKAGE__->register_method(
170     method    => "create_hold_batch",
171     api_name  => "open-ils.circ.holds.create.override.batch",
172     stream => 1,
173     signature => {
174         desc  => '@see open-ils.circ.holds.create.batch',
175     }
176 );
177
178
179 sub create_hold_batch {
180     my( $self, $conn, $auth, $hold_list, $oargs ) = @_;
181     (my $method = $self->api_name) =~ s/\.batch//og;
182     foreach (@$hold_list) {
183         my ($res) = $self->method_lookup($method)->run($auth, $_, $oargs);
184         $conn->respond($res);
185     }
186     return undef;
187 }
188
189
190 __PACKAGE__->register_method(
191     method    => "create_hold",
192     api_name  => "open-ils.circ.holds.create",
193     signature => {
194         desc => "Create a new hold for an item.  From a permissions perspective, " .
195                 "the login session is used as the 'requestor' of the hold.  "      .
196                 "The hold recipient is determined by the 'usr' setting within the hold object. " .
197                 'First we verify the requestor has holds request permissions.  '         .
198                 'Then we verify that the recipient is allowed to make the given hold.  ' .
199                 'If not, we see if the requestor has "override" capabilities.  If not, ' .
200                 'a permission exception is returned.  If permissions allow, we cycle '   .
201                 'through the set of holds objects and create.  '                         .
202                 'If the recipient does not have permission to place multiple holds '     .
203                 'on a single title and said operation is attempted, a permission '       .
204                 'exception is returned',
205         params => [
206             { desc => 'Authentication token',               type => 'string' },
207             { desc => 'Hold object for hold to be created',
208                 type => 'object', class => 'ahr' }
209         ],
210         return => {
211             desc => 'New ahr ID on success, -1 on missing arg, event (or ref to array of events) on error(s)',
212         },
213     }
214 );
215
216 __PACKAGE__->register_method(
217     method    => "create_hold",
218     api_name  => "open-ils.circ.holds.create.override",
219     notes     => '@see open-ils.circ.holds.create',
220     signature => {
221         desc  => "If the recipient is not allowed to receive the requested hold, " .
222                  "call this method to attempt the override",
223         params => [
224             { desc => 'Authentication token',               type => 'string' },
225             {
226                 desc => 'Hold object for hold to be created',
227                 type => 'object', class => 'ahr'
228             }
229         ],
230         return => {
231             desc => 'New hold (ahr) ID on success, -1 on missing arg, event (or ref to array of events) on error(s)',
232         },
233     }
234 );
235
236 sub create_hold {
237     my( $self, $conn, $auth, $hold, $oargs ) = @_;
238     return -1 unless $hold;
239     my $e = new_editor(authtoken=>$auth, xact=>1);
240     return $e->die_event unless $e->checkauth;
241
242     my $override = 0;
243     if ($self->api_name =~ /override/) {
244         $override = 1;
245         $oargs = { all => 1 } unless defined $oargs;
246     }
247
248     my @events;
249
250     my $requestor = $e->requestor;
251     my $recipient = $requestor;
252
253     if( $requestor->id ne $hold->usr ) {
254         # Make sure the requestor is allowed to place holds for
255         # the recipient if they are not the same people
256         $recipient = $e->retrieve_actor_user($hold->usr)  or return $e->die_event;
257         $e->allowed('REQUEST_HOLDS', $recipient->home_ou) or return $e->die_event;
258     }
259
260     # If the related org setting tells us to, block if patron privs have expired
261     my $expire_setting = $U->ou_ancestor_setting_value($recipient->home_ou, OILS_SETTING_BLOCK_HOLD_FOR_EXPIRED_PATRON);
262     if ($expire_setting) {
263         my $expire = DateTime::Format::ISO8601->new->parse_datetime(
264             cleanse_ISO8601($recipient->expire_date));
265
266         push( @events, OpenILS::Event->new(
267             'PATRON_ACCOUNT_EXPIRED',
268             "payload" => {"fail_part" => "actor.usr.privs_expired"}
269             )) if( CORE::time > $expire->epoch ) ;
270     }
271
272     # Now make sure the recipient is allowed to receive the specified hold
273     my $porg = $recipient->home_ou;
274     my $rid  = $e->requestor->id;
275     my $t    = $hold->hold_type;
276
277     # See if a duplicate hold already exists
278     my $sargs = {
279         usr              => $recipient->id,
280         hold_type        => $t,
281         fulfillment_time => undef,
282         target           => $hold->target,
283         cancel_time      => undef,
284     };
285
286     $sargs->{holdable_formats} = $hold->holdable_formats if $t eq 'M';
287
288     my $existing = $e->search_action_hold_request($sargs);
289     push( @events, OpenILS::Event->new('HOLD_EXISTS')) if @$existing;
290
291     my $checked_out = hold_item_is_checked_out($e, $recipient->id, $hold->hold_type, $hold->target);
292     push( @events, OpenILS::Event->new('HOLD_ITEM_CHECKED_OUT')) if $checked_out;
293
294     if ( $t eq OILS_HOLD_TYPE_METARECORD ) {
295         return $e->die_event unless $e->allowed('MR_HOLDS',     $porg);
296     } elsif ( $t eq OILS_HOLD_TYPE_TITLE ) {
297         return $e->die_event unless $e->allowed('TITLE_HOLDS',  $porg);
298     } elsif ( $t eq OILS_HOLD_TYPE_VOLUME ) {
299         return $e->die_event unless $e->allowed('VOLUME_HOLDS', $porg);
300     } elsif ( $t eq OILS_HOLD_TYPE_MONOPART ) {
301         return $e->die_event unless $e->allowed('TITLE_HOLDS', $porg);
302     } elsif ( $t eq OILS_HOLD_TYPE_ISSUANCE ) {
303         return $e->die_event unless $e->allowed('ISSUANCE_HOLDS', $porg);
304     } elsif ( $t eq OILS_HOLD_TYPE_COPY ) {
305         return $e->die_event unless $e->allowed('COPY_HOLDS',   $porg);
306     } elsif ( $t eq OILS_HOLD_TYPE_FORCE || $t eq OILS_HOLD_TYPE_RECALL ) {
307         my $copy = $e->retrieve_asset_copy($hold->target)
308             or return $e->die_event;
309         if ( $t eq OILS_HOLD_TYPE_FORCE ) {
310             return $e->die_event unless $e->allowed('COPY_HOLDS_FORCE',   $copy->circ_lib);
311         } elsif ( $t eq OILS_HOLD_TYPE_RECALL ) {
312             return $e->die_event unless $e->allowed('COPY_HOLDS_RECALL',   $copy->circ_lib);
313         }
314     }
315
316     if( @events ) {
317         if (!$override) {
318             $e->rollback;
319             return \@events;
320         }
321         for my $evt (@events) {
322             next unless $evt;
323             my $name = $evt->{textcode};
324             if($oargs->{all} || grep { $_ eq $name } @{$oargs->{events}}) {
325                 return $e->die_event unless $e->allowed("$name.override", $porg);
326             } else {
327                 $e->rollback;
328                 return \@events;
329             }
330         }
331     }
332
333         # Check for hold expiration in the past, and set it to empty string.
334         $hold->expire_time(undef) if ($hold->expire_time && $U->datecmp($hold->expire_time) == -1);
335
336     # set the configured expire time
337     unless($hold->expire_time) {
338         $hold->expire_time(calculate_expire_time($recipient->home_ou));
339     }
340
341     $hold->requestor($e->requestor->id);
342     $hold->request_lib($e->requestor->ws_ou);
343     $hold->selection_ou($hold->pickup_lib) unless $hold->selection_ou;
344     $hold = $e->create_action_hold_request($hold) or return $e->die_event;
345
346     $e->commit;
347
348     $conn->respond_complete($hold->id);
349
350     $U->storagereq(
351         'open-ils.storage.action.hold_request.copy_targeter',
352         undef, $hold->id ) unless $U->is_true($hold->frozen);
353
354     return undef;
355 }
356
357 # makes sure that a user has permission to place the type of requested hold
358 # returns the Perm exception if not allowed, returns undef if all is well
359 sub _check_holds_perm {
360     my($type, $user_id, $org_id) = @_;
361
362     my $evt;
363     if ($type eq "M") {
364         $evt = $apputils->check_perms($user_id, $org_id, "MR_HOLDS"    );
365     } elsif ($type eq "T") {
366         $evt = $apputils->check_perms($user_id, $org_id, "TITLE_HOLDS" );
367     } elsif($type eq "V") {
368         $evt = $apputils->check_perms($user_id, $org_id, "VOLUME_HOLDS");
369     } elsif($type eq "C") {
370         $evt = $apputils->check_perms($user_id, $org_id, "COPY_HOLDS"  );
371     }
372
373     return $evt if $evt;
374     return undef;
375 }
376
377 # tests if the given user is allowed to place holds on another's behalf
378 sub _check_request_holds_perm {
379     my $user_id = shift;
380     my $org_id  = shift;
381     if (my $evt = $apputils->check_perms(
382         $user_id, $org_id, "REQUEST_HOLDS")) {
383         return $evt;
384     }
385 }
386
387 my $ses_is_req_note = 'The login session is the requestor.  If the requestor is different from the user, ' .
388                       'then the requestor must have VIEW_HOLD permissions';
389
390 __PACKAGE__->register_method(
391     method    => "retrieve_holds_by_id",
392     api_name  => "open-ils.circ.holds.retrieve_by_id",
393     signature => {
394         desc   => "Retrieve the hold, with hold transits attached, for the specified ID.  $ses_is_req_note",
395         params => [
396             { desc => 'Authentication token', type => 'string' },
397             { desc => 'Hold ID',              type => 'number' }
398         ],
399         return => {
400             desc => 'Hold object with transits attached, event on error',
401         }
402     }
403 );
404
405
406 sub retrieve_holds_by_id {
407     my($self, $client, $auth, $hold_id) = @_;
408     my $e = new_editor(authtoken=>$auth);
409     $e->checkauth or return $e->event;
410     $e->allowed('VIEW_HOLD') or return $e->event;
411
412     my $holds = $e->search_action_hold_request(
413         [
414             { id =>  $hold_id , fulfillment_time => undef },
415             {
416                 order_by => { ahr => "request_time" },
417                 flesh => 1,
418                 flesh_fields => {ahr => ['notes']}
419             }
420         ]
421     );
422
423     flesh_hold_transits($holds);
424     flesh_hold_notices($holds, $e);
425     return $holds;
426 }
427
428
429 __PACKAGE__->register_method(
430     method    => "retrieve_holds",
431     api_name  => "open-ils.circ.holds.retrieve",
432     signature => {
433         desc   => "Retrieves all the holds, with hold transits attached, for the specified user.  $ses_is_req_note",
434         params => [
435             { desc => 'Authentication token', type => 'string'  },
436             { desc => 'User ID',              type => 'integer' },
437             { desc => 'Available Only',       type => 'boolean' }
438         ],
439         return => {
440             desc => 'list of holds, event on error',
441         }
442    }
443 );
444
445 __PACKAGE__->register_method(
446     method        => "retrieve_holds",
447     api_name      => "open-ils.circ.holds.id_list.retrieve",
448     authoritative => 1,
449     signature     => {
450         desc   => "Retrieves all the hold IDs, for the specified user.  $ses_is_req_note",
451         params => [
452             { desc => 'Authentication token', type => 'string'  },
453             { desc => 'User ID',              type => 'integer' },
454             { desc => 'Available Only',       type => 'boolean' }
455         ],
456         return => {
457             desc => 'list of holds, event on error',
458         }
459    }
460 );
461
462 __PACKAGE__->register_method(
463     method        => "retrieve_holds",
464     api_name      => "open-ils.circ.holds.canceled.retrieve",
465     authoritative => 1,
466     signature     => {
467         desc   => "Retrieves all the cancelled holds for the specified user.  $ses_is_req_note",
468         params => [
469             { desc => 'Authentication token', type => 'string'  },
470             { desc => 'User ID',              type => 'integer' }
471         ],
472         return => {
473             desc => 'list of holds, event on error',
474         }
475    }
476 );
477
478 __PACKAGE__->register_method(
479     method        => "retrieve_holds",
480     api_name      => "open-ils.circ.holds.canceled.id_list.retrieve",
481     authoritative => 1,
482     signature     => {
483         desc   => "Retrieves list of cancelled hold IDs for the specified user.  $ses_is_req_note",
484         params => [
485             { desc => 'Authentication token', type => 'string'  },
486             { desc => 'User ID',              type => 'integer' }
487         ],
488         return => {
489             desc => 'list of hold IDs, event on error',
490         }
491    }
492 );
493
494
495 sub retrieve_holds {
496     my ($self, $client, $auth, $user_id, $available) = @_;
497
498     my $e = new_editor(authtoken=>$auth);
499     return $e->event unless $e->checkauth;
500     $user_id = $e->requestor->id unless defined $user_id;
501
502     my $notes_filter = {staff => 'f'};
503     my $user = $e->retrieve_actor_user($user_id) or return $e->event;
504     unless($user_id == $e->requestor->id) {
505         if($e->allowed('VIEW_HOLD', $user->home_ou)) {
506             $notes_filter = {staff => 't'}
507         } else {
508             my $allowed = OpenILS::Application::Actor::Friends->friend_perm_allowed(
509                 $e, $user_id, $e->requestor->id, 'hold.view');
510             return $e->event unless $allowed;
511         }
512     } else {
513         # staff member looking at his/her own holds can see staff and non-staff notes
514         $notes_filter = {} if $e->allowed('VIEW_HOLD', $user->home_ou);
515     }
516
517     my $holds_query = {
518         select => {ahr => ['id']},
519         from => 'ahr',
520         where => {usr => $user_id, fulfillment_time => undef}
521     };
522
523     if($self->api_name =~ /canceled/) {
524
525         # Fetch the canceled holds
526         # order cancelled holds by cancel time, most recent first
527
528         $holds_query->{order_by} = [{class => 'ahr', field => 'cancel_time', direction => 'desc'}];
529
530         my $cancel_age;
531         my $cancel_count = $U->ou_ancestor_setting_value(
532                 $e->requestor->ws_ou, 'circ.holds.canceled.display_count', $e);
533
534         unless($cancel_count) {
535             $cancel_age = $U->ou_ancestor_setting_value(
536                 $e->requestor->ws_ou, 'circ.holds.canceled.display_age', $e);
537
538             # if no settings are defined, default to last 10 cancelled holds
539             $cancel_count = 10 unless $cancel_age;
540         }
541
542         if($cancel_count) { # limit by count
543
544             $holds_query->{where}->{cancel_time} = {'!=' => undef};
545             $holds_query->{limit} = $cancel_count;
546
547         } elsif($cancel_age) { # limit by age
548
549             # find all of the canceled holds that were canceled within the configured time frame
550             my $date = DateTime->now->subtract(seconds => OpenSRF::Utils::interval_to_seconds($cancel_age));
551             $date = $U->epoch2ISO8601($date->epoch);
552             $holds_query->{where}->{cancel_time} = {'>=' => $date};
553         }
554
555     } else {
556
557         # order non-cancelled holds by ready-for-pickup, then active, followed by suspended
558         # "compare" sorts false values to the front.  testing pickup_lib != current_shelf_lib
559         # will sort by pl = csl > pl != csl > followed by csl is null;
560         $holds_query->{order_by} = [
561             {   class => 'ahr',
562                 field => 'pickup_lib',
563                 compare => {'!='  => {'+ahr' => 'current_shelf_lib'}}},
564             {class => 'ahr', field => 'shelf_time'},
565             {class => 'ahr', field => 'frozen'},
566             {class => 'ahr', field => 'request_time'}
567
568         ];
569         $holds_query->{where}->{cancel_time} = undef;
570         if($available) {
571             $holds_query->{where}->{shelf_time} = {'!=' => undef};
572             # Maybe?
573             $holds_query->{where}->{pickup_lib} = {'=' => 'current_shelf_lib'};
574         }
575     }
576
577     my $hold_ids = $e->json_query($holds_query);
578     $hold_ids = [ map { $_->{id} } @$hold_ids ];
579
580     return $hold_ids if $self->api_name =~ /id_list/;
581
582     my @holds;
583     for my $hold_id ( @$hold_ids ) {
584
585         my $hold = $e->retrieve_action_hold_request($hold_id);
586         $hold->notes($e->search_action_hold_request_note({hold => $hold_id, %$notes_filter}));
587
588         $hold->transit(
589             $e->search_action_hold_transit_copy([
590                 {hold => $hold->id},
591                 {order_by => {ahtc => 'source_send_time desc'}, limit => 1}])->[0]
592         );
593
594         push(@holds, $hold);
595     }
596
597     return \@holds;
598 }
599
600
601 __PACKAGE__->register_method(
602     method   => 'user_hold_count',
603     api_name => 'open-ils.circ.hold.user.count'
604 );
605
606 sub user_hold_count {
607     my ( $self, $conn, $auth, $userid ) = @_;
608     my $e = new_editor( authtoken => $auth );
609     return $e->event unless $e->checkauth;
610     my $patron = $e->retrieve_actor_user($userid)
611         or return $e->event;
612     return $e->event unless $e->allowed( 'VIEW_HOLD', $patron->home_ou );
613     return __user_hold_count( $self, $e, $userid );
614 }
615
616 sub __user_hold_count {
617     my ( $self, $e, $userid ) = @_;
618     my $holds = $e->search_action_hold_request(
619         {
620             usr              => $userid,
621             fulfillment_time => undef,
622             cancel_time      => undef,
623         },
624         { idlist => 1 }
625     );
626
627     return scalar(@$holds);
628 }
629
630
631 __PACKAGE__->register_method(
632     method   => "retrieve_holds_by_pickup_lib",
633     api_name => "open-ils.circ.holds.retrieve_by_pickup_lib",
634     notes    =>
635       "Retrieves all the holds, with hold transits attached, for the specified pickup_ou id."
636 );
637
638 __PACKAGE__->register_method(
639     method   => "retrieve_holds_by_pickup_lib",
640     api_name => "open-ils.circ.holds.id_list.retrieve_by_pickup_lib",
641     notes    => "Retrieves all the hold ids for the specified pickup_ou id. "
642 );
643
644 sub retrieve_holds_by_pickup_lib {
645     my ($self, $client, $login_session, $ou_id) = @_;
646
647     #FIXME -- put an appropriate permission check here
648     #my( $user, $target, $evt ) = $apputils->checkses_requestor(
649     #    $login_session, $user_id, 'VIEW_HOLD' );
650     #return $evt if $evt;
651
652     my $holds = $apputils->simplereq(
653         'open-ils.cstore',
654         "open-ils.cstore.direct.action.hold_request.search.atomic",
655         {
656             pickup_lib =>  $ou_id ,
657             fulfillment_time => undef,
658             cancel_time => undef
659         },
660         { order_by => { ahr => "request_time" } }
661     );
662
663     if ( ! $self->api_name =~ /id_list/ ) {
664         flesh_hold_transits($holds);
665         return $holds;
666     }
667     # else id_list
668     return [ map { $_->id } @$holds ];
669 }
670
671
672 __PACKAGE__->register_method(
673     method   => "uncancel_hold",
674     api_name => "open-ils.circ.hold.uncancel"
675 );
676
677 sub uncancel_hold {
678     my($self, $client, $auth, $hold_id) = @_;
679     my $e = new_editor(authtoken=>$auth, xact=>1);
680     return $e->die_event unless $e->checkauth;
681
682     my $hold = $e->retrieve_action_hold_request($hold_id)
683         or return $e->die_event;
684     return $e->die_event unless $e->allowed('CANCEL_HOLDS', $hold->request_lib);
685
686     if ($hold->fulfillment_time) {
687         $e->rollback;
688         return 0;
689     }
690     unless ($hold->cancel_time) {
691         $e->rollback;
692         return 1;
693     }
694
695     # if configured to reset the request time, also reset the expire time
696     if($U->ou_ancestor_setting_value(
697         $hold->request_lib, 'circ.holds.uncancel.reset_request_time', $e)) {
698
699         $hold->request_time('now');
700         $hold->expire_time(calculate_expire_time($hold->request_lib));
701     }
702
703     $hold->clear_cancel_time;
704     $hold->clear_cancel_cause;
705     $hold->clear_cancel_note;
706     $hold->clear_shelf_time;
707     $hold->clear_current_copy;
708     $hold->clear_capture_time;
709     $hold->clear_prev_check_time;
710     $hold->clear_shelf_expire_time;
711     $hold->clear_current_shelf_lib;
712
713     $e->update_action_hold_request($hold) or return $e->die_event;
714     $e->commit;
715
716     $U->storagereq('open-ils.storage.action.hold_request.copy_targeter', undef, $hold_id);
717
718     return 1;
719 }
720
721
722 __PACKAGE__->register_method(
723     method    => "cancel_hold",
724     api_name  => "open-ils.circ.hold.cancel",
725     signature => {
726         desc   => 'Cancels the specified hold.  The login session is the requestor.  If the requestor is different from the usr field ' .
727                   'on the hold, the requestor must have CANCEL_HOLDS permissions. The hold may be either the hold object or the hold id',
728         param  => [
729             {desc => 'Authentication token',  type => 'string'},
730             {desc => 'Hold ID',               type => 'number'},
731             {desc => 'Cause of Cancellation', type => 'string'},
732             {desc => 'Note',                  type => 'string'}
733         ],
734         return => {
735             desc => '1 on success, event on error'
736         }
737     }
738 );
739
740 sub cancel_hold {
741     my($self, $client, $auth, $holdid, $cause, $note) = @_;
742
743     my $e = new_editor(authtoken=>$auth, xact=>1);
744     return $e->die_event unless $e->checkauth;
745
746     my $hold = $e->retrieve_action_hold_request($holdid)
747         or return $e->die_event;
748
749     if( $e->requestor->id ne $hold->usr ) {
750         return $e->die_event unless $e->allowed('CANCEL_HOLDS');
751     }
752
753     if ($hold->cancel_time) {
754         $e->rollback;
755         return 1;
756     }
757
758     # If the hold is captured, reset the copy status
759     if( $hold->capture_time and $hold->current_copy ) {
760
761         my $copy = $e->retrieve_asset_copy($hold->current_copy)
762             or return $e->die_event;
763
764         if( $copy->status == OILS_COPY_STATUS_ON_HOLDS_SHELF ) {
765          $logger->info("canceling hold $holdid whose item is on the holds shelf");
766 #            $logger->info("setting copy to status 'reshelving' on hold cancel");
767 #            $copy->status(OILS_COPY_STATUS_RESHELVING);
768 #            $copy->editor($e->requestor->id);
769 #            $copy->edit_date('now');
770 #            $e->update_asset_copy($copy) or return $e->event;
771
772         } elsif( $copy->status == OILS_COPY_STATUS_IN_TRANSIT ) {
773
774             my $hid = $hold->id;
775             $logger->warn("! canceling hold [$hid] that is in transit");
776             my $transid = $e->search_action_hold_transit_copy({hold=>$hold->id},{idlist=>1})->[0];
777
778             if( $transid ) {
779                 my $trans = $e->retrieve_action_transit_copy($transid);
780                 # Leave the transit alive, but  set the copy status to
781                 # reshelving so it will be properly reshelved when it gets back home
782                 if( $trans ) {
783                     $trans->copy_status( OILS_COPY_STATUS_RESHELVING );
784                     $e->update_action_transit_copy($trans) or return $e->die_event;
785                 }
786             }
787         }
788     }
789
790     $hold->cancel_time('now');
791     $hold->cancel_cause($cause);
792     $hold->cancel_note($note);
793     $e->update_action_hold_request($hold)
794         or return $e->die_event;
795
796     delete_hold_copy_maps($self, $e, $hold->id);
797
798     $e->commit;
799
800     # re-fetch the hold to pick up the real cancel_time (not "now") for A/T
801     $e->xact_begin;
802     $hold = $e->retrieve_action_hold_request($hold->id) or return $e->die_event;
803     $e->rollback;
804
805     if ($e->requestor->id == $hold->usr) {
806         $U->create_events_for_hook('hold_request.cancel.patron', $hold, $hold->pickup_lib);
807     } else {
808         $U->create_events_for_hook('hold_request.cancel.staff', $hold, $hold->pickup_lib);
809     }
810
811     return 1;
812 }
813
814 sub delete_hold_copy_maps {
815     my $class  = shift;
816     my $editor = shift;
817     my $holdid = shift;
818
819     my $maps = $editor->search_action_hold_copy_map({hold=>$holdid});
820     for(@$maps) {
821         $editor->delete_action_hold_copy_map($_)
822             or return $editor->event;
823     }
824     return undef;
825 }
826
827
828 my $update_hold_desc = 'The login session is the requestor. '       .
829    'If the requestor is different from the usr field on the hold, ' .
830    'the requestor must have UPDATE_HOLDS permissions. '             .
831    'If supplying a hash of hold data, "id" must be included. '      .
832    'The hash is ignored if a hold object is supplied, '             .
833    'so you should supply only one kind of hold data argument.'      ;
834
835 __PACKAGE__->register_method(
836     method    => "update_hold",
837     api_name  => "open-ils.circ.hold.update",
838     signature => {
839         desc   => "Updates the specified hold.  $update_hold_desc",
840         params => [
841             {desc => 'Authentication token',         type => 'string'},
842             {desc => 'Hold Object',                  type => 'object'},
843             {desc => 'Hash of values to be applied', type => 'object'}
844         ],
845         return => {
846             desc => 'Hold ID on success, event on error',
847             # type => 'number'
848         }
849     }
850 );
851
852 __PACKAGE__->register_method(
853     method    => "batch_update_hold",
854     api_name  => "open-ils.circ.hold.update.batch",
855     stream    => 1,
856     signature => {
857         desc   => "Updates the specified hold(s).  $update_hold_desc",
858         params => [
859             {desc => 'Authentication token',                    type => 'string'},
860             {desc => 'Array of hold obejcts',                   type => 'array' },
861             {desc => 'Array of hashes of values to be applied', type => 'array' }
862         ],
863         return => {
864             desc => 'Hold ID per success, event per error',
865         }
866     }
867 );
868
869 sub update_hold {
870     my($self, $client, $auth, $hold, $values) = @_;
871     my $e = new_editor(authtoken=>$auth, xact=>1);
872     return $e->die_event unless $e->checkauth;
873     my $resp = update_hold_impl($self, $e, $hold, $values);
874     if ($U->event_code($resp)) {
875         $e->rollback;
876         return $resp;
877     }
878     $e->commit;     # FIXME: update_hold_impl already does $e->commit  ??
879     return $resp;
880 }
881
882 sub batch_update_hold {
883     my($self, $client, $auth, $hold_list, $values_list) = @_;
884     my $e = new_editor(authtoken=>$auth);
885     return $e->die_event unless $e->checkauth;
886
887     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.
888     $hold_list   ||= [];
889     $values_list ||= [];      # FIXME: either move this above $count declaration, or send an event if both lists undef.  Probably the latter.
890
891 # FIXME: Failing over to [] guarantees warnings for "Use of unitialized value" in update_hold_impl call.
892 # FIXME: We should be sure we only call update_hold_impl with hold object OR hash, not both.
893
894     for my $idx (0..$count-1) {
895         $e->xact_begin;
896         my $resp = update_hold_impl($self, $e, $hold_list->[$idx], $values_list->[$idx]);
897         $e->xact_commit unless $U->event_code($resp);
898         $client->respond($resp);
899     }
900
901     $e->disconnect;
902     return undef;       # not in the register return type, assuming we should always have at least one list populated
903 }
904
905 sub update_hold_impl {
906     my($self, $e, $hold, $values) = @_;
907     my $hold_status;
908     my $need_retarget = 0;
909
910     unless($hold) {
911         $hold = $e->retrieve_action_hold_request($values->{id})
912             or return $e->die_event;
913         for my $k (keys %$values) {
914             # Outside of pickup_lib (covered by the first regex) I don't know when these would currently change.
915             # But hey, why not cover things that may happen later?
916             if ($k =~ '_(lib|ou)$' || $k eq 'target' || $k eq 'hold_type' || $k eq 'requestor' || $k eq 'selection_depth' || $k eq 'holdable_formats') {
917                 if (defined $values->{$k} && defined $hold->$k() && $values->{$k} ne $hold->$k()) {
918                     # Value changed? RETARGET!
919                     $need_retarget = 1;
920                 } elsif (defined $hold->$k() != defined $values->{$k}) {
921                     # Value being set or cleared? RETARGET!
922                     $need_retarget = 1;
923                 }
924             }
925             if (defined $values->{$k}) {
926                 $hold->$k($values->{$k});
927             } else {
928                 my $f = "clear_$k"; $hold->$f();
929             }
930         }
931     }
932
933     my $orig_hold = $e->retrieve_action_hold_request($hold->id)
934         or return $e->die_event;
935
936     # don't allow the user to be changed
937     return OpenILS::Event->new('BAD_PARAMS') if $hold->usr != $orig_hold->usr;
938
939     if($hold->usr ne $e->requestor->id) {
940         # if the hold is for a different user, make sure the
941         # requestor has the appropriate permissions
942         my $usr = $e->retrieve_actor_user($hold->usr)
943             or return $e->die_event;
944         return $e->die_event unless $e->allowed('UPDATE_HOLD', $usr->home_ou);
945     }
946
947
948     # --------------------------------------------------------------
949     # Changing the request time is like playing God
950     # --------------------------------------------------------------
951     if($hold->request_time ne $orig_hold->request_time) {
952         return OpenILS::Event->new('BAD_PARAMS') if $hold->fulfillment_time;
953         return $e->die_event unless $e->allowed('UPDATE_HOLD_REQUEST_TIME', $hold->pickup_lib);
954     }
955
956
957     # --------------------------------------------------------------
958     # Code for making sure staff have appropriate permissons for cut_in_line
959     # This, as is, doesn't prevent a user from cutting their own holds in line
960     # but needs to
961     # --------------------------------------------------------------
962     if($U->is_true($hold->cut_in_line) ne $U->is_true($orig_hold->cut_in_line)) {
963         return $e->die_event unless $e->allowed('UPDATE_HOLD_REQUEST_TIME', $hold->pickup_lib);
964     }
965
966
967     # --------------------------------------------------------------
968     # Disallow hold suspencion if the hold is already captured.
969     # --------------------------------------------------------------
970     if ($U->is_true($hold->frozen) and not $U->is_true($orig_hold->frozen)) {
971         $hold_status = _hold_status($e, $hold);
972         if ($hold_status > 2 && $hold_status != 7) { # hold is captured
973             $logger->info("bypassing hold freeze on captured hold");
974             return OpenILS::Event->new('HOLD_SUSPEND_AFTER_CAPTURE');
975         }
976     }
977
978
979     # --------------------------------------------------------------
980     # if the hold is on the holds shelf or in transit and the pickup
981     # lib changes we need to create a new transit.
982     # --------------------------------------------------------------
983     if($orig_hold->pickup_lib ne $hold->pickup_lib) {
984
985         $hold_status = _hold_status($e, $hold) unless $hold_status;
986
987         if($hold_status == 3) { # in transit
988
989             return $e->die_event unless $e->allowed('UPDATE_PICKUP_LIB_FROM_TRANSIT', $orig_hold->pickup_lib);
990             return $e->die_event unless $e->allowed('UPDATE_PICKUP_LIB_FROM_TRANSIT', $hold->pickup_lib);
991
992             $logger->info("updating pickup lib for hold ".$hold->id." while already in transit");
993
994             # update the transit to reflect the new pickup location
995             my $transit = $e->search_action_hold_transit_copy(
996                 {hold=>$hold->id, dest_recv_time => undef})->[0]
997                 or return $e->die_event;
998
999             $transit->prev_dest($transit->dest); # mark the previous destination on the transit
1000             $transit->dest($hold->pickup_lib);
1001             $e->update_action_hold_transit_copy($transit) or return $e->die_event;
1002
1003         } elsif($hold_status == 4 or $hold_status == 5 or $hold_status == 8) { # on holds shelf
1004
1005             return $e->die_event unless $e->allowed('UPDATE_PICKUP_LIB_FROM_HOLDS_SHELF', $orig_hold->pickup_lib);
1006             return $e->die_event unless $e->allowed('UPDATE_PICKUP_LIB_FROM_HOLDS_SHELF', $hold->pickup_lib);
1007
1008             $logger->info("updating pickup lib for hold ".$hold->id." while on holds shelf");
1009
1010             if ($hold->pickup_lib eq $orig_hold->current_shelf_lib) {
1011                 # This can happen if the pickup lib is changed while the hold is
1012                 # on the shelf, then changed back to the original pickup lib.
1013                 # Restore the original shelf_expire_time to prevent abuse.
1014                 set_hold_shelf_expire_time(undef, $hold, $e, $hold->shelf_time);
1015
1016             } else {
1017                 # clear to prevent premature shelf expiration
1018                 $hold->clear_shelf_expire_time;
1019             }
1020         }
1021     }
1022
1023     if($U->is_true($hold->frozen)) {
1024         $logger->info("clearing current_copy and check_time for frozen hold ".$hold->id);
1025         $hold->clear_current_copy;
1026         $hold->clear_prev_check_time;
1027         # Clear expire_time to prevent frozen holds from expiring.
1028         $logger->info("clearing expire_time for frozen hold ".$hold->id);
1029         $hold->clear_expire_time;
1030     }
1031
1032     # If the hold_expire_time is in the past && is not equal to the
1033     # original expire_time, then reset the expire time to be in the
1034     # future.
1035     if ($hold->expire_time && $U->datecmp($hold->expire_time) == -1 && $U->datecmp($hold->expire_time, $orig_hold->expire_time) != 0) {
1036         $hold->expire_time(calculate_expire_time($hold->request_lib));
1037     }
1038
1039     # If the hold is reactivated, reset the expire_time.
1040     if(!$U->is_true($hold->frozen) && $U->is_true($orig_hold->frozen)) {
1041         $logger->info("Reset expire_time on activated hold ".$hold->id);
1042         $hold->expire_time(calculate_expire_time($hold->request_lib));
1043     }
1044
1045     $e->update_action_hold_request($hold) or return $e->die_event;
1046     $e->commit;
1047
1048     if(!$U->is_true($hold->frozen) && $U->is_true($orig_hold->frozen)) {
1049         $logger->info("Running targeter on activated hold ".$hold->id);
1050         $U->storagereq( 'open-ils.storage.action.hold_request.copy_targeter', undef, $hold->id );
1051     }
1052
1053     # a change to mint-condition changes the set of potential copies, so retarget the hold;
1054     if($U->is_true($hold->mint_condition) and !$U->is_true($orig_hold->mint_condition)) {
1055         _reset_hold($self, $e->requestor, $hold)
1056     } elsif($need_retarget && !defined $hold->capture_time()) { # If needed, retarget the hold due to changes
1057         $U->storagereq(
1058             'open-ils.storage.action.hold_request.copy_targeter', undef, $hold->id );
1059     }
1060
1061     return $hold->id;
1062 }
1063
1064 # this does not update the hold in the DB.  It only
1065 # sets the shelf_expire_time field on the hold object.
1066 # start_time is optional and defaults to 'now'
1067 sub set_hold_shelf_expire_time {
1068     my ($class, $hold, $editor, $start_time) = @_;
1069
1070     my $shelf_expire = $U->ou_ancestor_setting_value(
1071         $hold->pickup_lib,
1072         'circ.holds.default_shelf_expire_interval',
1073         $editor
1074     );
1075
1076     return undef unless $shelf_expire;
1077
1078     $start_time = ($start_time) ?
1079         DateTime::Format::ISO8601->new->parse_datetime(cleanse_ISO8601($start_time)) :
1080         DateTime->now;
1081
1082     my $seconds = OpenSRF::Utils->interval_to_seconds($shelf_expire);
1083     my $expire_time = $start_time->add(seconds => $seconds);
1084
1085     # if the shelf expire time overlaps with a pickup lib's
1086     # closed date, push it out to the first open date
1087     my $dateinfo = $U->storagereq(
1088         'open-ils.storage.actor.org_unit.closed_date.overlap',
1089         $hold->pickup_lib, $expire_time->strftime('%FT%T%z'));
1090
1091     if($dateinfo) {
1092         my $dt_parser = DateTime::Format::ISO8601->new;
1093         $expire_time = $dt_parser->parse_datetime(cleanse_ISO8601($dateinfo->{end}));
1094
1095         # TODO: enable/disable time bump via setting?
1096         $expire_time->set(hour => '23', minute => '59', second => '59');
1097
1098         $logger->info("circulator: shelf_expire_time overlaps".
1099             " with closed date, pushing expire time to $expire_time");
1100     }
1101
1102     $hold->shelf_expire_time($expire_time->strftime('%FT%T%z'));
1103     return undef;
1104 }
1105
1106
1107 sub transit_hold {
1108     my($e, $orig_hold, $hold, $copy) = @_;
1109     my $src  = $orig_hold->pickup_lib;
1110     my $dest = $hold->pickup_lib;
1111
1112     $logger->info("putting hold into transit on pickup_lib update");
1113
1114     my $transit = Fieldmapper::action::hold_transit_copy->new;
1115     $transit->hold($hold->id);
1116     $transit->source($src);
1117     $transit->dest($dest);
1118     $transit->target_copy($copy->id);
1119     $transit->source_send_time('now');
1120     $transit->copy_status(OILS_COPY_STATUS_ON_HOLDS_SHELF);
1121
1122     $copy->status(OILS_COPY_STATUS_IN_TRANSIT);
1123     $copy->editor($e->requestor->id);
1124     $copy->edit_date('now');
1125
1126     $e->create_action_hold_transit_copy($transit) or return $e->die_event;
1127     $e->update_asset_copy($copy) or return $e->die_event;
1128     return undef;
1129 }
1130
1131 # if the hold is frozen, this method ensures that the hold is not "targeted",
1132 # that is, it clears the current_copy and prev_check_time to essentiallly
1133 # reset the hold.  If it is being activated, it runs the targeter in the background
1134 sub update_hold_if_frozen {
1135     my($self, $e, $hold, $orig_hold) = @_;
1136     return if $hold->capture_time;
1137
1138     if($U->is_true($hold->frozen)) {
1139         $logger->info("clearing current_copy and check_time for frozen hold ".$hold->id);
1140         $hold->clear_current_copy;
1141         $hold->clear_prev_check_time;
1142
1143     } else {
1144         if($U->is_true($orig_hold->frozen)) {
1145             $logger->info("Running targeter on activated hold ".$hold->id);
1146             $U->storagereq( 'open-ils.storage.action.hold_request.copy_targeter', undef, $hold->id );
1147         }
1148     }
1149 }
1150
1151 __PACKAGE__->register_method(
1152     method    => "hold_note_CUD",
1153     api_name  => "open-ils.circ.hold_request.note.cud",
1154     signature => {
1155         desc   => 'Create, update or delete a hold request note.  If the operator (from Auth. token) '
1156                 . 'is not the owner of the hold, the UPDATE_HOLD permission is required',
1157         params => [
1158             { desc => 'Authentication token', type => 'string' },
1159             { desc => 'Hold note object',     type => 'object' }
1160         ],
1161         return => {
1162             desc => 'Returns the note ID, event on error'
1163         },
1164     }
1165 );
1166
1167 sub hold_note_CUD {
1168     my($self, $conn, $auth, $note) = @_;
1169
1170     my $e = new_editor(authtoken => $auth, xact => 1);
1171     return $e->die_event unless $e->checkauth;
1172
1173     my $hold = $e->retrieve_action_hold_request($note->hold)
1174         or return $e->die_event;
1175
1176     if($hold->usr ne $e->requestor->id) {
1177         my $usr = $e->retrieve_actor_user($hold->usr);
1178         return $e->die_event unless $e->allowed('UPDATE_HOLD', $usr->home_ou);
1179         $note->staff('t') if $note->isnew;
1180     }
1181
1182     if($note->isnew) {
1183         $e->create_action_hold_request_note($note) or return $e->die_event;
1184     } elsif($note->ischanged) {
1185         $e->update_action_hold_request_note($note) or return $e->die_event;
1186     } elsif($note->isdeleted) {
1187         $e->delete_action_hold_request_note($note) or return $e->die_event;
1188     }
1189
1190     $e->commit;
1191     return $note->id;
1192 }
1193
1194
1195 __PACKAGE__->register_method(
1196     method    => "retrieve_hold_status",
1197     api_name  => "open-ils.circ.hold.status.retrieve",
1198     signature => {
1199         desc   => 'Calculates the current status of the hold. The requestor must have '      .
1200                   'VIEW_HOLD permissions if the hold is for a user other than the requestor' ,
1201         param  => [
1202             { desc => 'Hold ID', type => 'number' }
1203         ],
1204         return => {
1205             # type => 'number',     # event sometimes
1206             desc => <<'END_OF_DESC'
1207 Returns event on error or:
1208 -1 on error (for now),
1209  1 for 'waiting for copy to become available',
1210  2 for 'waiting for copy capture',
1211  3 for 'in transit',
1212  4 for 'arrived',
1213  5 for 'hold-shelf-delay'
1214  6 for 'canceled'
1215  7 for 'suspended'
1216  8 for 'captured, on wrong hold shelf'
1217 END_OF_DESC
1218         }
1219     }
1220 );
1221
1222 sub retrieve_hold_status {
1223     my($self, $client, $auth, $hold_id) = @_;
1224
1225     my $e = new_editor(authtoken => $auth);
1226     return $e->event unless $e->checkauth;
1227     my $hold = $e->retrieve_action_hold_request($hold_id)
1228         or return $e->event;
1229
1230     if( $e->requestor->id != $hold->usr ) {
1231         return $e->event unless $e->allowed('VIEW_HOLD');
1232     }
1233
1234     return _hold_status($e, $hold);
1235
1236 }
1237
1238 sub _hold_status {
1239     my($e, $hold) = @_;
1240     if ($hold->cancel_time) {
1241         return 6;
1242     }
1243     if ($U->is_true($hold->frozen) && !$hold->capture_time) {
1244         return 7;
1245     }
1246     if ($hold->current_shelf_lib and $hold->current_shelf_lib ne $hold->pickup_lib) {
1247         return 8;
1248     }
1249     return 1 unless $hold->current_copy;
1250     return 2 unless $hold->capture_time;
1251
1252     my $copy = $hold->current_copy;
1253     unless( ref $copy ) {
1254         $copy = $e->retrieve_asset_copy($hold->current_copy)
1255             or return $e->event;
1256     }
1257
1258     return 3 if $copy->status == OILS_COPY_STATUS_IN_TRANSIT;
1259
1260     if($copy->status == OILS_COPY_STATUS_ON_HOLDS_SHELF) {
1261
1262         my $hs_wait_interval = $U->ou_ancestor_setting_value($hold->pickup_lib, 'circ.hold_shelf_status_delay');
1263         return 4 unless $hs_wait_interval;
1264
1265         # if a hold_shelf_status_delay interval is defined and start_time plus
1266         # the interval is greater than now, consider the hold to be in the virtual
1267         # "on its way to the holds shelf" status. Return 5.
1268
1269         my $transit    = $e->search_action_hold_transit_copy({hold => $hold->id})->[0];
1270         my $start_time = ($transit) ? $transit->dest_recv_time : $hold->capture_time;
1271         $start_time    = DateTime::Format::ISO8601->new->parse_datetime(cleanse_ISO8601($start_time));
1272         my $end_time   = $start_time->add(seconds => OpenSRF::Utils::interval_to_seconds($hs_wait_interval));
1273
1274         return 5 if $end_time > DateTime->now;
1275         return 4;
1276     }
1277
1278     return -1;  # error
1279 }
1280
1281
1282
1283 __PACKAGE__->register_method(
1284     method    => "retrieve_hold_queue_stats",
1285     api_name  => "open-ils.circ.hold.queue_stats.retrieve",
1286     signature => {
1287         desc   => 'Returns summary data about the state of a hold',
1288         params => [
1289             { desc => 'Authentication token',  type => 'string'},
1290             { desc => 'Hold ID', type => 'number'},
1291         ],
1292         return => {
1293             desc => q/Summary object with keys:
1294                 total_holds : total holds in queue
1295                 queue_position : current queue position
1296                 potential_copies : number of potential copies for this hold
1297                 estimated_wait : estimated wait time in days
1298                 status : hold status
1299                      -1 => error or unexpected state,
1300                      1 => 'waiting for copy to become available',
1301                      2 => 'waiting for copy capture',
1302                      3 => 'in transit',
1303                      4 => 'arrived',
1304                      5 => 'hold-shelf-delay'
1305             /,
1306             type => 'object'
1307         }
1308     }
1309 );
1310
1311 sub retrieve_hold_queue_stats {
1312     my($self, $conn, $auth, $hold_id) = @_;
1313     my $e = new_editor(authtoken => $auth);
1314     return $e->event unless $e->checkauth;
1315     my $hold = $e->retrieve_action_hold_request($hold_id) or return $e->event;
1316     if($e->requestor->id != $hold->usr) {
1317         return $e->event unless $e->allowed('VIEW_HOLD');
1318     }
1319     return retrieve_hold_queue_status_impl($e, $hold);
1320 }
1321
1322 sub retrieve_hold_queue_status_impl {
1323     my $e = shift;
1324     my $hold = shift;
1325
1326     # The holds queue is defined as the distinct set of holds that share at
1327     # least one potential copy with the context hold, plus any holds that
1328     # share the same hold type and target.  The latter part exists to
1329     # accomodate holds that currently have no potential copies
1330     my $q_holds = $e->json_query({
1331
1332         # fetch cut_in_line and request_time since they're in the order_by
1333         # and we're asking for distinct values
1334         select => {ahr => ['id', 'cut_in_line', 'request_time']},
1335         from   => 'ahr',
1336         where => {
1337             id => { in => {
1338                 select => { ahcm => ['hold'] },
1339                 from   => {
1340                     'ahcm' => {
1341                         'ahcm2' => {
1342                             'class' => 'ahcm',
1343                             'field' => 'target_copy',
1344                             'fkey'  => 'target_copy'
1345                         }
1346                     }
1347                 },
1348                 where => { '+ahcm2' => { hold => $hold->id } },
1349                 distinct => 1
1350             }}
1351         },
1352         order_by => [
1353             {
1354                 "class" => "ahr",
1355                 "field" => "cut_in_line",
1356                 "transform" => "coalesce",
1357                 "params" => [ 0 ],
1358                 "direction" => "desc"
1359             },
1360             { "class" => "ahr", "field" => "request_time" }
1361         ],
1362         distinct => 1
1363     });
1364
1365     if (!@$q_holds) { # none? maybe we don't have a map ...
1366         $q_holds = $e->json_query({
1367             select => {ahr => ['id', 'cut_in_line', 'request_time']},
1368             from   => 'ahr',
1369             order_by => [
1370                 {
1371                     "class" => "ahr",
1372                     "field" => "cut_in_line",
1373                     "transform" => "coalesce",
1374                     "params" => [ 0 ],
1375                     "direction" => "desc"
1376                 },
1377                 { "class" => "ahr", "field" => "request_time" }
1378             ],
1379             where    => {
1380                 hold_type => $hold->hold_type,
1381                 target    => $hold->target
1382            }
1383         });
1384     }
1385
1386
1387     my $qpos = 1;
1388     for my $h (@$q_holds) {
1389         last if $h->{id} == $hold->id;
1390         $qpos++;
1391     }
1392
1393     my $hold_data = $e->json_query({
1394         select => {
1395             acp => [ {column => 'id', transform => 'count', aggregate => 1, alias => 'count'} ],
1396             ccm => [ {column =>'avg_wait_time'} ]
1397         },
1398         from => {
1399             ahcm => {
1400                 acp => {
1401                     join => {
1402                         ccm => {type => 'left'}
1403                     }
1404                 }
1405             }
1406         },
1407         where => {'+ahcm' => {hold => $hold->id} }
1408     });
1409
1410     my $user_org = $e->json_query({select => {au => ['home_ou']}, from => 'au', where => {id => $hold->usr}})->[0]->{home_ou};
1411
1412     my $default_wait = $U->ou_ancestor_setting_value($user_org, OILS_SETTING_HOLD_ESIMATE_WAIT_INTERVAL);
1413     my $min_wait = $U->ou_ancestor_setting_value($user_org, 'circ.holds.min_estimated_wait_interval');
1414     $min_wait = OpenSRF::Utils::interval_to_seconds($min_wait || '0 seconds');
1415     $default_wait ||= '0 seconds';
1416
1417     # Estimated wait time is the average wait time across the set
1418     # of potential copies, divided by the number of potential copies
1419     # times the queue position.
1420
1421     my $combined_secs = 0;
1422     my $num_potentials = 0;
1423
1424     for my $wait_data (@$hold_data) {
1425         my $count += $wait_data->{count};
1426         $combined_secs += $count *
1427             OpenSRF::Utils::interval_to_seconds($wait_data->{avg_wait_time} || $default_wait);
1428         $num_potentials += $count;
1429     }
1430
1431     my $estimated_wait = -1;
1432
1433     if($num_potentials) {
1434         my $avg_wait = $combined_secs / $num_potentials;
1435         $estimated_wait = $qpos * ($avg_wait / $num_potentials);
1436         $estimated_wait = $min_wait if $estimated_wait < $min_wait and $estimated_wait != -1;
1437     }
1438
1439     return {
1440         total_holds      => scalar(@$q_holds),
1441         queue_position   => $qpos,
1442         potential_copies => $num_potentials,
1443         status           => _hold_status( $e, $hold ),
1444         estimated_wait   => int($estimated_wait)
1445     };
1446 }
1447
1448
1449 sub fetch_open_hold_by_current_copy {
1450     my $class = shift;
1451     my $copyid = shift;
1452     my $hold = $apputils->simplereq(
1453         'open-ils.cstore',
1454         'open-ils.cstore.direct.action.hold_request.search.atomic',
1455         { current_copy =>  $copyid , cancel_time => undef, fulfillment_time => undef });
1456     return $hold->[0] if ref($hold);
1457     return undef;
1458 }
1459
1460 sub fetch_related_holds {
1461     my $class = shift;
1462     my $copyid = shift;
1463     return $apputils->simplereq(
1464         'open-ils.cstore',
1465         'open-ils.cstore.direct.action.hold_request.search.atomic',
1466         { current_copy =>  $copyid , cancel_time => undef, fulfillment_time => undef });
1467 }
1468
1469
1470 __PACKAGE__->register_method(
1471     method    => "hold_pull_list",
1472     api_name  => "open-ils.circ.hold_pull_list.retrieve",
1473     signature => {
1474         desc   => 'Returns (reference to) a list of holds that need to be "pulled" by a given location. ' .
1475                   'The location is determined by the login session.',
1476         params => [
1477             { desc => 'Limit (optional)',  type => 'number'},
1478             { desc => 'Offset (optional)', type => 'number'},
1479         ],
1480         return => {
1481             desc => 'reference to a list of holds, or event on failure',
1482         }
1483     }
1484 );
1485
1486 __PACKAGE__->register_method(
1487     method    => "hold_pull_list",
1488     api_name  => "open-ils.circ.hold_pull_list.id_list.retrieve",
1489     signature => {
1490         desc   => 'Returns (reference to) a list of holds IDs that need to be "pulled" by a given location. ' .
1491                   'The location is determined by the login session.',
1492         params => [
1493             { desc => 'Limit (optional)',  type => 'number'},
1494             { desc => 'Offset (optional)', type => 'number'},
1495         ],
1496         return => {
1497             desc => 'reference to a list of holds, or event on failure',
1498         }
1499     }
1500 );
1501
1502 __PACKAGE__->register_method(
1503     method    => "hold_pull_list",
1504     api_name  => "open-ils.circ.hold_pull_list.retrieve.count",
1505     signature => {
1506         desc   => 'Returns a count of holds that need to be "pulled" by a given location. ' .
1507                   'The location is determined by the login session.',
1508         params => [
1509             { desc => 'Limit (optional)',  type => 'number'},
1510             { desc => 'Offset (optional)', type => 'number'},
1511         ],
1512         return => {
1513             desc => 'Holds count (integer), or event on failure',
1514             # type => 'number'
1515         }
1516     }
1517 );
1518
1519
1520 sub hold_pull_list {
1521     my( $self, $conn, $authtoken, $limit, $offset ) = @_;
1522     my( $reqr, $evt ) = $U->checkses($authtoken);
1523     return $evt if $evt;
1524
1525     my $org = $reqr->ws_ou || $reqr->home_ou;
1526     # the perm locaiton shouldn't really matter here since holds
1527     # will exist all over and VIEW_HOLDS should be universal
1528     $evt = $U->check_perms($reqr->id, $org, 'VIEW_HOLD');
1529     return $evt if $evt;
1530
1531     if($self->api_name =~ /count/) {
1532
1533         my $count = $U->storagereq(
1534             'open-ils.storage.direct.action.hold_request.pull_list.current_copy_circ_lib.status_filtered.count',
1535             $org, $limit, $offset );
1536
1537         $logger->info("Grabbing pull list for org unit $org with $count items");
1538         return $count;
1539
1540     } elsif( $self->api_name =~ /id_list/ ) {
1541         return $U->storagereq(
1542             'open-ils.storage.direct.action.hold_request.pull_list.id_list.current_copy_circ_lib.status_filtered.atomic',
1543             $org, $limit, $offset );
1544
1545     } else {
1546         return $U->storagereq(
1547             'open-ils.storage.direct.action.hold_request.pull_list.search.current_copy_circ_lib.status_filtered.atomic',
1548             $org, $limit, $offset );
1549     }
1550 }
1551
1552 __PACKAGE__->register_method(
1553     method    => "print_hold_pull_list",
1554     api_name  => "open-ils.circ.hold_pull_list.print",
1555     signature => {
1556         desc   => 'Returns an HTML-formatted holds pull list',
1557         params => [
1558             { desc => 'Authtoken', type => 'string'},
1559             { desc => 'Org unit ID.  Optional, defaults to workstation org unit', type => 'number'},
1560         ],
1561         return => {
1562             desc => 'HTML string',
1563             type => 'string'
1564         }
1565     }
1566 );
1567
1568 sub print_hold_pull_list {
1569     my($self, $client, $auth, $org_id) = @_;
1570
1571     my $e = new_editor(authtoken=>$auth);
1572     return $e->event unless $e->checkauth;
1573
1574     $org_id = (defined $org_id) ? $org_id : $e->requestor->ws_ou;
1575     return $e->event unless $e->allowed('VIEW_HOLD', $org_id);
1576
1577     my $hold_ids = $U->storagereq(
1578         'open-ils.storage.direct.action.hold_request.pull_list.id_list.current_copy_circ_lib.status_filtered.atomic',
1579         $org_id, 10000);
1580
1581     return undef unless @$hold_ids;
1582
1583     $client->status(new OpenSRF::DomainObject::oilsContinueStatus);
1584
1585     # Holds will /NOT/ be in order after this ...
1586     my $holds = $e->search_action_hold_request({id => $hold_ids}, {substream => 1});
1587     $client->status(new OpenSRF::DomainObject::oilsContinueStatus);
1588
1589     # ... so we must resort.
1590     my $hold_map = +{map { $_->id => $_ } @$holds};
1591     my $sorted_holds = [];
1592     push @$sorted_holds, $hold_map->{$_} foreach @$hold_ids;
1593
1594     return $U->fire_object_event(
1595         undef, "ahr.format.pull_list", $sorted_holds,
1596         $org_id, undef, undef, $client
1597     );
1598
1599 }
1600
1601 __PACKAGE__->register_method(
1602     method    => "print_hold_pull_list_stream",
1603     stream   => 1,
1604     api_name  => "open-ils.circ.hold_pull_list.print.stream",
1605     signature => {
1606         desc   => 'Returns a stream of fleshed holds',
1607         params => [
1608             { desc => 'Authtoken', type => 'string'},
1609             { 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)',
1610               type => 'object'
1611             },
1612         ],
1613         return => {
1614             desc => 'A stream of fleshed holds',
1615             type => 'object'
1616         }
1617     }
1618 );
1619
1620 sub print_hold_pull_list_stream {
1621     my($self, $client, $auth, $params) = @_;
1622
1623     my $e = new_editor(authtoken=>$auth);
1624     return $e->die_event unless $e->checkauth;
1625
1626     delete($$params{org_id}) unless (int($$params{org_id}));
1627     delete($$params{limit}) unless (int($$params{limit}));
1628     delete($$params{offset}) unless (int($$params{offset}));
1629     delete($$params{chunk_size}) unless (int($$params{chunk_size}));
1630     delete($$params{chunk_size}) if  ($$params{chunk_size} && $$params{chunk_size} > 50); # keep the size reasonable
1631     $$params{chunk_size} ||= 10;
1632
1633     $$params{org_id} = (defined $$params{org_id}) ? $$params{org_id}: $e->requestor->ws_ou;
1634     return $e->die_event unless $e->allowed('VIEW_HOLD', $$params{org_id });
1635
1636     my $sort = [];
1637     if ($$params{sort} && @{ $$params{sort} }) {
1638         for my $s (@{ $$params{sort} }) {
1639             if ($s eq 'acplo.position') {
1640                 push @$sort, {
1641                     "class" => "acplo", "field" => "position",
1642                     "transform" => "coalesce", "params" => [999]
1643                 };
1644             } elsif ($s eq 'prefix') {
1645                 push @$sort, {"class" => "acnp", "field" => "label_sortkey"};
1646             } elsif ($s eq 'call_number') {
1647                 push @$sort, {"class" => "acn", "field" => "label_sortkey"};
1648             } elsif ($s eq 'suffix') {
1649                 push @$sort, {"class" => "acns", "field" => "label_sortkey"};
1650             } elsif ($s eq 'request_time') {
1651                 push @$sort, {"class" => "ahr", "field" => "request_time"};
1652             }
1653         }
1654     } else {
1655         push @$sort, {"class" => "ahr", "field" => "request_time"};
1656     }
1657
1658     my $holds_ids = $e->json_query(
1659         {
1660             "select" => {"ahr" => ["id"]},
1661             "from" => {
1662                 "ahr" => {
1663                     "acp" => {
1664                         "field" => "id",
1665                         "fkey" => "current_copy",
1666                         "filter" => {
1667                             "circ_lib" => $$params{org_id}, "status" => [0,7]
1668                         },
1669                         "join" => {
1670                             "acn" => {
1671                                 "field" => "id",
1672                                 "fkey" => "call_number",
1673                                 "join" => {
1674                                     "acnp" => {
1675                                         "field" => "id",
1676                                         "fkey" => "prefix"
1677                                     },
1678                                     "acns" => {
1679                                         "field" => "id",
1680                                         "fkey" => "suffix"
1681                                     }
1682                                 }
1683                             },
1684                             "acplo" => {
1685                                 "field" => "org",
1686                                 "fkey" => "circ_lib",
1687                                 "type" => "left",
1688                                 "filter" => {
1689                                     "location" => {"=" => {"+acp" => "location"}}
1690                                 }
1691                             }
1692                         }
1693                     }
1694                 }
1695             },
1696             "where" => {
1697                 "+ahr" => {
1698                     "capture_time" => undef,
1699                     "cancel_time" => undef,
1700                     "-or" => [
1701                         {"expire_time" => undef },
1702                         {"expire_time" => {">" => "now"}}
1703                     ]
1704                 }
1705             },
1706             (@$sort ? (order_by => $sort) : ()),
1707             ($$params{limit} ? (limit => $$params{limit}) : ()),
1708             ($$params{offset} ? (offset => $$params{offset}) : ())
1709         }, {"substream" => 1}
1710     ) or return $e->die_event;
1711
1712     $logger->info("about to stream back " . scalar(@$holds_ids) . " holds");
1713
1714     my @chunk;
1715     for my $hid (@$holds_ids) {
1716         push @chunk, $e->retrieve_action_hold_request([
1717             $hid->{"id"}, {
1718                 "flesh" => 3,
1719                 "flesh_fields" => {
1720                     "ahr" => ["usr", "current_copy"],
1721                     "au"  => ["card"],
1722                     "acp" => ["location", "call_number", "parts"],
1723                     "acn" => ["record","prefix","suffix"]
1724                 }
1725             }
1726         ]);
1727
1728         if (@chunk >= $$params{chunk_size}) {
1729             $client->respond( \@chunk );
1730             @chunk = ();
1731         }
1732     }
1733     $client->respond_complete( \@chunk ) if (@chunk);
1734     $e->disconnect;
1735     return undef;
1736 }
1737
1738
1739
1740 __PACKAGE__->register_method(
1741     method        => 'fetch_hold_notify',
1742     api_name      => 'open-ils.circ.hold_notification.retrieve_by_hold',
1743     authoritative => 1,
1744     signature     => q/
1745 Returns a list of hold notification objects based on hold id.
1746 @param authtoken The loggin session key
1747 @param holdid The id of the hold whose notifications we want to retrieve
1748 @return An array of hold notification objects, event on error.
1749 /
1750 );
1751
1752 sub fetch_hold_notify {
1753     my( $self, $conn, $authtoken, $holdid ) = @_;
1754     my( $requestor, $evt ) = $U->checkses($authtoken);
1755     return $evt if $evt;
1756     my ($hold, $patron);
1757     ($hold, $evt) = $U->fetch_hold($holdid);
1758     return $evt if $evt;
1759     ($patron, $evt) = $U->fetch_user($hold->usr);
1760     return $evt if $evt;
1761
1762     $evt = $U->check_perms($requestor->id, $patron->home_ou, 'VIEW_HOLD_NOTIFICATION');
1763     return $evt if $evt;
1764
1765     $logger->info("User ".$requestor->id." fetching hold notifications for hold $holdid");
1766     return $U->cstorereq(
1767         'open-ils.cstore.direct.action.hold_notification.search.atomic', {hold => $holdid} );
1768 }
1769
1770
1771 __PACKAGE__->register_method(
1772     method    => 'create_hold_notify',
1773     api_name  => 'open-ils.circ.hold_notification.create',
1774     signature => q/
1775 Creates a new hold notification object
1776 @param authtoken The login session key
1777 @param notification The hold notification object to create
1778 @return ID of the new object on success, Event on error
1779 /
1780 );
1781
1782 sub create_hold_notify {
1783    my( $self, $conn, $auth, $note ) = @_;
1784    my $e = new_editor(authtoken=>$auth, xact=>1);
1785    return $e->die_event unless $e->checkauth;
1786
1787    my $hold = $e->retrieve_action_hold_request($note->hold)
1788       or return $e->die_event;
1789    my $patron = $e->retrieve_actor_user($hold->usr)
1790       or return $e->die_event;
1791
1792    return $e->die_event unless
1793       $e->allowed('CREATE_HOLD_NOTIFICATION', $patron->home_ou);
1794
1795    $note->notify_staff($e->requestor->id);
1796    $e->create_action_hold_notification($note) or return $e->die_event;
1797    $e->commit;
1798    return $note->id;
1799 }
1800
1801 __PACKAGE__->register_method(
1802     method    => 'create_hold_note',
1803     api_name  => 'open-ils.circ.hold_note.create',
1804     signature => q/
1805         Creates a new hold request note object
1806         @param authtoken The login session key
1807         @param note The hold note object to create
1808         @return ID of the new object on success, Event on error
1809         /
1810 );
1811
1812 sub create_hold_note {
1813    my( $self, $conn, $auth, $note ) = @_;
1814    my $e = new_editor(authtoken=>$auth, xact=>1);
1815    return $e->die_event unless $e->checkauth;
1816
1817    my $hold = $e->retrieve_action_hold_request($note->hold)
1818       or return $e->die_event;
1819    my $patron = $e->retrieve_actor_user($hold->usr)
1820       or return $e->die_event;
1821
1822    return $e->die_event unless
1823       $e->allowed('UPDATE_HOLD', $patron->home_ou); # FIXME: Using permcrud perm listed in fm_IDL.xml for ahrn.  Probably want something more specific
1824
1825    $e->create_action_hold_request_note($note) or return $e->die_event;
1826    $e->commit;
1827    return $note->id;
1828 }
1829
1830 __PACKAGE__->register_method(
1831     method    => 'reset_hold',
1832     api_name  => 'open-ils.circ.hold.reset',
1833     signature => q/
1834         Un-captures and un-targets a hold, essentially returning
1835         it to the state it was in directly after it was placed,
1836         then attempts to re-target the hold
1837         @param authtoken The login session key
1838         @param holdid The id of the hold
1839     /
1840 );
1841
1842
1843 sub reset_hold {
1844     my( $self, $conn, $auth, $holdid ) = @_;
1845     my $reqr;
1846     my ($hold, $evt) = $U->fetch_hold($holdid);
1847     return $evt if $evt;
1848     ($reqr, $evt) = $U->checksesperm($auth, 'UPDATE_HOLD');
1849     return $evt if $evt;
1850     $evt = _reset_hold($self, $reqr, $hold);
1851     return $evt if $evt;
1852     return 1;
1853 }
1854
1855
1856 __PACKAGE__->register_method(
1857     method   => 'reset_hold_batch',
1858     api_name => 'open-ils.circ.hold.reset.batch'
1859 );
1860
1861 sub reset_hold_batch {
1862     my($self, $conn, $auth, $hold_ids) = @_;
1863
1864     my $e = new_editor(authtoken => $auth);
1865     return $e->event unless $e->checkauth;
1866
1867     for my $hold_id ($hold_ids) {
1868
1869         my $hold = $e->retrieve_action_hold_request(
1870             [$hold_id, {flesh => 1, flesh_fields => {ahr => ['usr']}}])
1871             or return $e->event;
1872
1873         next unless $e->allowed('UPDATE_HOLD', $hold->usr->home_ou);
1874         _reset_hold($self, $e->requestor, $hold);
1875     }
1876
1877     return 1;
1878 }
1879
1880
1881 sub _reset_hold {
1882     my ($self, $reqr, $hold) = @_;
1883
1884     my $e = new_editor(xact =>1, requestor => $reqr);
1885
1886     $logger->info("reseting hold ".$hold->id);
1887
1888     my $hid = $hold->id;
1889
1890     if( $hold->capture_time and $hold->current_copy ) {
1891
1892         my $copy = $e->retrieve_asset_copy($hold->current_copy)
1893             or return $e->die_event;
1894
1895         if( $copy->status == OILS_COPY_STATUS_ON_HOLDS_SHELF ) {
1896             $logger->info("setting copy to status 'reshelving' on hold retarget");
1897             $copy->status(OILS_COPY_STATUS_RESHELVING);
1898             $copy->editor($e->requestor->id);
1899             $copy->edit_date('now');
1900             $e->update_asset_copy($copy) or return $e->die_event;
1901
1902         } elsif( $copy->status == OILS_COPY_STATUS_IN_TRANSIT ) {
1903
1904             # We don't want the copy to remain "in transit"
1905             $copy->status(OILS_COPY_STATUS_RESHELVING);
1906             $logger->warn("! reseting hold [$hid] that is in transit");
1907             my $transid = $e->search_action_hold_transit_copy({hold=>$hold->id},{idlist=>1})->[0];
1908
1909             if( $transid ) {
1910                 my $trans = $e->retrieve_action_transit_copy($transid);
1911                 if( $trans ) {
1912                     $logger->info("Aborting transit [$transid] on hold [$hid] reset...");
1913                     my $evt = OpenILS::Application::Circ::Transit::__abort_transit($e, $trans, $copy, 1, 1);
1914                     $logger->info("Transit abort completed with result $evt");
1915                     unless ("$evt" eq 1) {
1916                         $e->rollback;
1917                         return $evt;
1918                     }
1919                 }
1920             }
1921         }
1922     }
1923
1924     $hold->clear_capture_time;
1925     $hold->clear_current_copy;
1926     $hold->clear_shelf_time;
1927     $hold->clear_shelf_expire_time;
1928     $hold->clear_current_shelf_lib;
1929
1930     $e->update_action_hold_request($hold) or return $e->die_event;
1931     $e->commit;
1932
1933     $U->storagereq(
1934         'open-ils.storage.action.hold_request.copy_targeter', undef, $hold->id );
1935
1936     return undef;
1937 }
1938
1939
1940 __PACKAGE__->register_method(
1941     method    => 'fetch_open_title_holds',
1942     api_name  => 'open-ils.circ.open_holds.retrieve',
1943     signature => q/
1944         Returns a list ids of un-fulfilled holds for a given title id
1945         @param authtoken The login session key
1946         @param id the id of the item whose holds we want to retrieve
1947         @param type The hold type - M, T, I, V, C, F, R
1948     /
1949 );
1950
1951 sub fetch_open_title_holds {
1952     my( $self, $conn, $auth, $id, $type, $org ) = @_;
1953     my $e = new_editor( authtoken => $auth );
1954     return $e->event unless $e->checkauth;
1955
1956     $type ||= "T";
1957     $org  ||= $e->requestor->ws_ou;
1958
1959 #    return $e->search_action_hold_request(
1960 #        { target => $id, hold_type => $type, fulfillment_time => undef }, {idlist=>1});
1961
1962     # XXX make me return IDs in the future ^--
1963     my $holds = $e->search_action_hold_request(
1964         {
1965             target           => $id,
1966             cancel_time      => undef,
1967             hold_type        => $type,
1968             fulfillment_time => undef
1969         }
1970     );
1971
1972     flesh_hold_transits($holds);
1973     return $holds;
1974 }
1975
1976
1977 sub flesh_hold_transits {
1978     my $holds = shift;
1979     for my $hold ( @$holds ) {
1980         $hold->transit(
1981             $apputils->simplereq(
1982                 'open-ils.cstore',
1983                 "open-ils.cstore.direct.action.hold_transit_copy.search.atomic",
1984                 { hold => $hold->id },
1985                 { order_by => { ahtc => 'id desc' }, limit => 1 }
1986             )->[0]
1987         );
1988     }
1989 }
1990
1991 sub flesh_hold_notices {
1992     my( $holds, $e ) = @_;
1993     $e ||= new_editor();
1994
1995     for my $hold (@$holds) {
1996         my $notices = $e->search_action_hold_notification(
1997             [
1998                 { hold => $hold->id },
1999                 { order_by => { anh => 'notify_time desc' } },
2000             ],
2001             {idlist=>1}
2002         );
2003
2004         $hold->notify_count(scalar(@$notices));
2005         if( @$notices ) {
2006             my $n = $e->retrieve_action_hold_notification($$notices[0])
2007                 or return $e->event;
2008             $hold->notify_time($n->notify_time);
2009         }
2010     }
2011 }
2012
2013
2014 __PACKAGE__->register_method(
2015     method    => 'fetch_captured_holds',
2016     api_name  => 'open-ils.circ.captured_holds.on_shelf.retrieve',
2017     stream    => 1,
2018     authoritative => 1,
2019     signature => q/
2020         Returns a list of un-fulfilled holds (on the Holds Shelf) for a given title id
2021         @param authtoken The login session key
2022         @param org The org id of the location in question
2023         @param match_copy A specific copy to limit to
2024     /
2025 );
2026
2027 __PACKAGE__->register_method(
2028     method    => 'fetch_captured_holds',
2029     api_name  => 'open-ils.circ.captured_holds.id_list.on_shelf.retrieve',
2030     stream    => 1,
2031     authoritative => 1,
2032     signature => q/
2033         Returns list ids of un-fulfilled holds (on the Holds Shelf) for a given title id
2034         @param authtoken The login session key
2035         @param org The org id of the location in question
2036         @param match_copy A specific copy to limit to
2037     /
2038 );
2039
2040 __PACKAGE__->register_method(
2041     method    => 'fetch_captured_holds',
2042     api_name  => 'open-ils.circ.captured_holds.id_list.expired_on_shelf.retrieve',
2043     stream    => 1,
2044     authoritative => 1,
2045     signature => q/
2046         Returns list ids of shelf-expired un-fulfilled holds for a given title id
2047         @param authtoken The login session key
2048         @param org The org id of the location in question
2049         @param match_copy A specific copy to limit to
2050     /
2051 );
2052
2053
2054 sub fetch_captured_holds {
2055     my( $self, $conn, $auth, $org, $match_copy ) = @_;
2056
2057     my $e = new_editor(authtoken => $auth);
2058     return $e->die_event unless $e->checkauth;
2059     return $e->die_event unless $e->allowed('VIEW_HOLD'); # XXX rely on editor perm
2060
2061     $org ||= $e->requestor->ws_ou;
2062
2063     my $current_copy = { '!=' => undef };
2064     $current_copy = { '=' => $match_copy } if $match_copy;
2065
2066     my $query = {
2067         select => { alhr => ['id'] },
2068         from   => {
2069             alhr => {
2070                 acp => {
2071                     field => 'id',
2072                     fkey  => 'current_copy'
2073                 },
2074             }
2075         },
2076         where => {
2077             '+acp' => { status => OILS_COPY_STATUS_ON_HOLDS_SHELF },
2078             '+alhr' => {
2079                 capture_time      => { "!=" => undef },
2080                 current_copy      => $current_copy,
2081                 fulfillment_time  => undef,
2082                 current_shelf_lib => $org
2083             }
2084         }
2085     };
2086     if($self->api_name =~ /expired/) {
2087         $query->{'where'}->{'+alhr'}->{'-or'} = {
2088                 shelf_expire_time => { '<' => 'now'},
2089                 cancel_time => { '!=' => undef },
2090         };
2091     }
2092     my $hold_ids = $e->json_query( $query );
2093
2094     for my $hold_id (@$hold_ids) {
2095         if($self->api_name =~ /id_list/) {
2096             $conn->respond($hold_id->{id});
2097             next;
2098         } else {
2099             $conn->respond(
2100                 $e->retrieve_action_hold_request([
2101                     $hold_id->{id},
2102                     {
2103                         flesh => 1,
2104                         flesh_fields => {ahr => ['notifications', 'transit', 'notes']},
2105                         order_by => {anh => 'notify_time desc'}
2106                     }
2107                 ])
2108             );
2109         }
2110     }
2111
2112     return undef;
2113 }
2114
2115 __PACKAGE__->register_method(
2116     method    => "print_expired_holds_stream",
2117     api_name  => "open-ils.circ.captured_holds.expired.print.stream",
2118     stream    => 1
2119 );
2120
2121 sub print_expired_holds_stream {
2122     my ($self, $client, $auth, $params) = @_;
2123
2124     # No need to check specific permissions: we're going to call another method
2125     # that will do that.
2126     my $e = new_editor("authtoken" => $auth);
2127     return $e->die_event unless $e->checkauth;
2128
2129     delete($$params{org_id}) unless (int($$params{org_id}));
2130     delete($$params{limit}) unless (int($$params{limit}));
2131     delete($$params{offset}) unless (int($$params{offset}));
2132     delete($$params{chunk_size}) unless (int($$params{chunk_size}));
2133     delete($$params{chunk_size}) if  ($$params{chunk_size} && $$params{chunk_size} > 50); # keep the size reasonable
2134     $$params{chunk_size} ||= 10;
2135
2136     $$params{org_id} = (defined $$params{org_id}) ? $$params{org_id}: $e->requestor->ws_ou;
2137
2138     my @hold_ids = $self->method_lookup(
2139         "open-ils.circ.captured_holds.id_list.expired_on_shelf.retrieve"
2140     )->run($auth, $params->{"org_id"});
2141
2142     if (!@hold_ids) {
2143         $e->disconnect;
2144         return;
2145     } elsif (defined $U->event_code($hold_ids[0])) {
2146         $e->disconnect;
2147         return $hold_ids[0];
2148     }
2149
2150     $logger->info("about to stream back up to " . scalar(@hold_ids) . " expired holds");
2151
2152     while (@hold_ids) {
2153         my @hid_chunk = splice @hold_ids, 0, $params->{"chunk_size"};
2154
2155         my $result_chunk = $e->json_query({
2156             "select" => {
2157                 "acp" => ["barcode"],
2158                 "au" => [qw/
2159                     first_given_name second_given_name family_name alias
2160                 /],
2161                 "acn" => ["label"],
2162                 "bre" => ["marc"],
2163                 "acpl" => ["name"]
2164             },
2165             "from" => {
2166                 "ahr" => {
2167                     "acp" => {
2168                         "field" => "id", "fkey" => "current_copy",
2169                         "join" => {
2170                             "acn" => {
2171                                 "field" => "id", "fkey" => "call_number",
2172                                 "join" => {
2173                                     "bre" => {
2174                                         "field" => "id", "fkey" => "record"
2175                                     }
2176                                 }
2177                             },
2178                             "acpl" => {"field" => "id", "fkey" => "location"}
2179                         }
2180                     },
2181                     "au" => {"field" => "id", "fkey" => "usr"}
2182                 }
2183             },
2184             "where" => {"+ahr" => {"id" => \@hid_chunk}}
2185         }) or return $e->die_event;
2186         $client->respond($result_chunk);
2187     }
2188
2189     $e->disconnect;
2190     undef;
2191 }
2192
2193 __PACKAGE__->register_method(
2194     method    => "check_title_hold_batch",
2195     api_name  => "open-ils.circ.title_hold.is_possible.batch",
2196     stream    => 1,
2197     signature => {
2198         desc  => '@see open-ils.circ.title_hold.is_possible.batch',
2199         params => [
2200             { desc => 'Authentication token',     type => 'string'},
2201             { desc => 'Array of Hash of named parameters', type => 'array'},
2202         ],
2203         return => {
2204             desc => 'Array of response objects',
2205             type => 'array'
2206         }
2207     }
2208 );
2209
2210 sub check_title_hold_batch {
2211     my($self, $client, $authtoken, $param_list, $oargs) = @_;
2212     foreach (@$param_list) {
2213         my ($res) = $self->method_lookup('open-ils.circ.title_hold.is_possible')->run($authtoken, $_, $oargs);
2214         $client->respond($res);
2215     }
2216     return undef;
2217 }
2218
2219
2220 __PACKAGE__->register_method(
2221     method    => "check_title_hold",
2222     api_name  => "open-ils.circ.title_hold.is_possible",
2223     signature => {
2224         desc  => 'Determines if a hold were to be placed by a given user, ' .
2225              'whether or not said hold would have any potential copies to fulfill it.' .
2226              'The named paramaters of the second argument include: ' .
2227              'patronid, titleid, volume_id, copy_id, mrid, depth, pickup_lib, hold_type, selection_ou. ' .
2228              'See perldoc ' . __PACKAGE__ . ' for more info on these fields.' ,
2229         params => [
2230             { desc => 'Authentication token',     type => 'string'},
2231             { desc => 'Hash of named parameters', type => 'object'},
2232         ],
2233         return => {
2234             desc => 'List of new message IDs (empty if none)',
2235             type => 'array'
2236         }
2237     }
2238 );
2239
2240 =head3 check_title_hold (token, hash)
2241
2242 The named fields in the hash are:
2243
2244  patronid     - ID of the hold recipient  (required)
2245  depth        - hold range depth          (default 0)
2246  pickup_lib   - destination for hold, fallback value for selection_ou
2247  selection_ou - ID of org_unit establishing hard and soft hold boundary settings
2248  issuanceid   - ID of the issuance to be held, required for Issuance level hold
2249  partid       - ID of the monograph part to be held, required for monograph part level hold
2250  titleid      - ID (BRN) of the title to be held, required for Title level hold
2251  volume_id    - required for Volume level hold
2252  copy_id      - required for Copy level hold
2253  mrid         - required for Meta-record level hold
2254  hold_type    - T, C (or R or F), I, V or M for Title, Copy, Issuance, Volume or Meta-record  (default "T")
2255
2256 All key/value pairs are passed on to do_possibility_checks.
2257
2258 =cut
2259
2260 # FIXME: better params checking.  what other params are required, if any?
2261 # FIXME: 3 copies of values confusing: $x, $params->{x} and $params{x}
2262 # FIXME: for example, $depth gets a default value, but then $$params{depth} is still
2263 # used in conditionals, where it may be undefined, causing a warning.
2264 # FIXME: specify proper usage/interaction of selection_ou and pickup_lib
2265
2266 sub check_title_hold {
2267     my( $self, $client, $authtoken, $params ) = @_;
2268     my $e = new_editor(authtoken=>$authtoken);
2269     return $e->event unless $e->checkauth;
2270
2271     my %params       = %$params;
2272     my $depth        = $params{depth}        || 0;
2273     my $selection_ou = $params{selection_ou} || $params{pickup_lib};
2274     my $oargs        = $params{oargs}        || {};
2275
2276     if($oargs->{events}) {
2277         @{$oargs->{events}} = grep { $e->allowed($_ . '.override', $e->requestor->ws_ou); } @{$oargs->{events}};
2278     }
2279
2280
2281     my $patron = $e->retrieve_actor_user($params{patronid})
2282         or return $e->event;
2283
2284     if( $e->requestor->id ne $patron->id ) {
2285         return $e->event unless
2286             $e->allowed('VIEW_HOLD_PERMIT', $patron->home_ou);
2287     }
2288
2289     return OpenILS::Event->new('PATRON_BARRED') if $U->is_true($patron->barred);
2290
2291     my $request_lib = $e->retrieve_actor_org_unit($e->requestor->ws_ou)
2292         or return $e->event;
2293
2294     my $soft_boundary = $U->ou_ancestor_setting_value($selection_ou, OILS_SETTING_HOLD_SOFT_BOUNDARY);
2295     my $hard_boundary = $U->ou_ancestor_setting_value($selection_ou, OILS_SETTING_HOLD_HARD_BOUNDARY);
2296
2297     my @status = ();
2298     my $return_depth = $hard_boundary; # default depth to return on success
2299     if(defined $soft_boundary and $depth < $soft_boundary) {
2300         # work up the tree and as soon as we find a potential copy, use that depth
2301         # also, make sure we don't go past the hard boundary if it exists
2302
2303         # our min boundary is the greater of user-specified boundary or hard boundary
2304         my $min_depth = (defined $hard_boundary and $hard_boundary > $depth) ?
2305             $hard_boundary : $depth;
2306
2307         my $depth = $soft_boundary;
2308         while($depth >= $min_depth) {
2309             $logger->info("performing hold possibility check with soft boundary $depth");
2310             @status = do_possibility_checks($e, $patron, $request_lib, $depth, %params);
2311             if ($status[0]) {
2312                 $return_depth = $depth;
2313                 last;
2314             }
2315             $depth--;
2316         }
2317     } elsif(defined $hard_boundary and $depth < $hard_boundary) {
2318         # there is no soft boundary, enforce the hard boundary if it exists
2319         $logger->info("performing hold possibility check with hard boundary $hard_boundary");
2320         @status = do_possibility_checks($e, $patron, $request_lib, $hard_boundary, %params);
2321     } else {
2322         # no boundaries defined, fall back to user specifed boundary or no boundary
2323         $logger->info("performing hold possibility check with no boundary");
2324         @status = do_possibility_checks($e, $patron, $request_lib, $params{depth}, %params);
2325     }
2326
2327     my $place_unfillable = 0;
2328     $place_unfillable = 1 if $e->allowed('PLACE_UNFILLABLE_HOLD', $e->requestor->ws_ou);
2329
2330     if ($status[0]) {
2331         return {
2332             "success" => 1,
2333             "depth" => $return_depth,
2334             "local_avail" => $status[1]
2335         };
2336     } elsif ($status[2]) {
2337         my $n = scalar @{$status[2]};
2338         return {"success" => 0, "last_event" => $status[2]->[$n - 1], "age_protected_copy" => $status[3], "place_unfillable" => $place_unfillable};
2339     } else {
2340         return {"success" => 0, "age_protected_copy" => $status[3], "place_unfillable" => $place_unfillable};
2341     }
2342 }
2343
2344
2345
2346 sub do_possibility_checks {
2347     my($e, $patron, $request_lib, $depth, %params) = @_;
2348
2349     my $issuanceid   = $params{issuanceid}      || "";
2350     my $partid       = $params{partid}      || "";
2351     my $titleid      = $params{titleid}      || "";
2352     my $volid        = $params{volume_id};
2353     my $copyid       = $params{copy_id};
2354     my $mrid         = $params{mrid}         || "";
2355     my $pickup_lib   = $params{pickup_lib};
2356     my $hold_type    = $params{hold_type}    || 'T';
2357     my $selection_ou = $params{selection_ou} || $pickup_lib;
2358     my $holdable_formats = $params{holdable_formats};
2359     my $oargs        = $params{oargs}        || {};
2360
2361
2362     my $copy;
2363     my $volume;
2364     my $title;
2365
2366     if( $hold_type eq OILS_HOLD_TYPE_FORCE || $hold_type eq OILS_HOLD_TYPE_RECALL || $hold_type eq OILS_HOLD_TYPE_COPY ) {
2367
2368         return $e->event unless $copy   = $e->retrieve_asset_copy($copyid);
2369         return $e->event unless $volume = $e->retrieve_asset_call_number($copy->call_number);
2370         return $e->event unless $title  = $e->retrieve_biblio_record_entry($volume->record);
2371
2372         return (1, 1, []) if( $hold_type eq OILS_HOLD_TYPE_RECALL || $hold_type eq OILS_HOLD_TYPE_FORCE);
2373         return verify_copy_for_hold(
2374             $patron, $e->requestor, $title, $copy, $pickup_lib, $request_lib, $oargs
2375         );
2376
2377     } elsif( $hold_type eq OILS_HOLD_TYPE_VOLUME ) {
2378
2379         return $e->event unless $volume = $e->retrieve_asset_call_number($volid);
2380         return $e->event unless $title  = $e->retrieve_biblio_record_entry($volume->record);
2381
2382         return _check_volume_hold_is_possible(
2383             $volume, $title, $depth, $request_lib, $patron, $e->requestor, $pickup_lib, $selection_ou, $oargs
2384         );
2385
2386     } elsif( $hold_type eq OILS_HOLD_TYPE_TITLE ) {
2387
2388         return _check_title_hold_is_possible(
2389             $titleid, $depth, $request_lib, $patron, $e->requestor, $pickup_lib, $selection_ou, undef, $oargs
2390         );
2391
2392     } elsif( $hold_type eq OILS_HOLD_TYPE_ISSUANCE ) {
2393
2394         return _check_issuance_hold_is_possible(
2395             $issuanceid, $depth, $request_lib, $patron, $e->requestor, $pickup_lib, $selection_ou, $oargs
2396         );
2397
2398     } elsif( $hold_type eq OILS_HOLD_TYPE_MONOPART ) {
2399
2400         return _check_monopart_hold_is_possible(
2401             $partid, $depth, $request_lib, $patron, $e->requestor, $pickup_lib, $selection_ou, $oargs
2402         );
2403
2404     } elsif( $hold_type eq OILS_HOLD_TYPE_METARECORD ) {
2405
2406         my $maps = $e->search_metabib_metarecord_source_map({metarecord=>$mrid});
2407         my @recs = map { $_->source } @$maps;
2408         my @status = ();
2409         for my $rec (@recs) {
2410             @status = _check_title_hold_is_possible(
2411                 $rec, $depth, $request_lib, $patron, $e->requestor, $pickup_lib, $selection_ou, $holdable_formats, $oargs
2412             );
2413             last if $status[0];
2414         }
2415         return @status;
2416     }
2417 #   else { Unrecognized hold_type ! }   # FIXME: return error? or 0?
2418 }
2419
2420 my %prox_cache;
2421 sub create_ranged_org_filter {
2422     my($e, $selection_ou, $depth) = @_;
2423
2424     # find the orgs from which this hold may be fulfilled,
2425     # based on the selection_ou and depth
2426
2427     my $top_org = $e->search_actor_org_unit([
2428         {parent_ou => undef},
2429         {flesh=>1, flesh_fields=>{aou=>['ou_type']}}])->[0];
2430     my %org_filter;
2431
2432     return () if $depth == $top_org->ou_type->depth;
2433
2434     my $org_list = $U->storagereq('open-ils.storage.actor.org_unit.descendants.atomic', $selection_ou, $depth);
2435     %org_filter = (circ_lib => []);
2436     push(@{$org_filter{circ_lib}}, $_->id) for @$org_list;
2437
2438     $logger->info("hold org filter at depth $depth and selection_ou ".
2439         "$selection_ou created list of @{$org_filter{circ_lib}}");
2440
2441     return %org_filter;
2442 }
2443
2444
2445 sub _check_title_hold_is_possible {
2446     my( $titleid, $depth, $request_lib, $patron, $requestor, $pickup_lib, $selection_ou, $holdable_formats, $oargs ) = @_;
2447
2448     my ($types, $formats, $lang);
2449     if (defined($holdable_formats)) {
2450         ($types, $formats, $lang) = split '-', $holdable_formats;
2451     }
2452
2453     my $e = new_editor();
2454     my %org_filter = create_ranged_org_filter($e, $selection_ou, $depth);
2455
2456     # this monster will grab the id and circ_lib of all of the "holdable" copies for the given record
2457     my $copies = $e->json_query(
2458         {
2459             select => { acp => ['id', 'circ_lib'] },
2460               from => {
2461                 acp => {
2462                     acn => {
2463                         field  => 'id',
2464                         fkey   => 'call_number',
2465                         'join' => {
2466                             bre => {
2467                                 field  => 'id',
2468                                 filter => { id => $titleid },
2469                                 fkey   => 'record'
2470                             },
2471                             mrd => {
2472                                 field  => 'record',
2473                                 fkey   => 'record',
2474                                 filter => {
2475                                     record => $titleid,
2476                                     ( $types   ? (item_type => [split '', $types])   : () ),
2477                                     ( $formats ? (item_form => [split '', $formats]) : () ),
2478                                     ( $lang    ? (item_lang => $lang)                : () )
2479                                 }
2480                             }
2481                         }
2482                     },
2483                     acpl => { field => 'id', filter => { holdable => 't'}, fkey => 'location' },
2484                     ccs  => { field => 'id', filter => { holdable => 't'}, fkey => 'status'   },
2485                     acpm => { field => 'target_copy', type => 'left' } # ignore part-linked copies
2486                 }
2487             },
2488             where => {
2489                 '+acp' => { circulate => 't', deleted => 'f', holdable => 't', %org_filter },
2490                 '+acpm' => { target_copy => undef } # ignore part-linked copies
2491             }
2492         }
2493     );
2494
2495     $logger->info("title possible found ".scalar(@$copies)." potential copies");
2496     return (
2497         0, 0, [
2498             new OpenILS::Event(
2499                 "HIGH_LEVEL_HOLD_HAS_NO_COPIES",
2500                 "payload" => {"fail_part" => "no_ultimate_items"}
2501             )
2502         ]
2503     ) unless @$copies;
2504
2505     # -----------------------------------------------------------------------
2506     # sort the copies into buckets based on their circ_lib proximity to
2507     # the patron's home_ou.
2508     # -----------------------------------------------------------------------
2509
2510     my $home_org = $patron->home_ou;
2511     my $req_org = $request_lib->id;
2512
2513     $logger->info("prox cache $home_org " . $prox_cache{$home_org});
2514
2515     $prox_cache{$home_org} =
2516         $e->search_actor_org_unit_proximity({from_org => $home_org})
2517         unless $prox_cache{$home_org};
2518     my $home_prox = $prox_cache{$home_org};
2519
2520     my %buckets;
2521     my %hash = map { ($_->to_org => $_->prox) } @$home_prox;
2522     push( @{$buckets{ $hash{$_->{circ_lib}} } }, $_->{id} ) for @$copies;
2523
2524     my @keys = sort { $a <=> $b } keys %buckets;
2525
2526
2527     if( $home_org ne $req_org ) {
2528       # -----------------------------------------------------------------------
2529       # shove the copies close to the request_lib into the primary buckets
2530       # directly before the farthest away copies.  That way, they are not
2531       # given priority, but they are checked before the farthest copies.
2532       # -----------------------------------------------------------------------
2533         $prox_cache{$req_org} =
2534             $e->search_actor_org_unit_proximity({from_org => $req_org})
2535             unless $prox_cache{$req_org};
2536         my $req_prox = $prox_cache{$req_org};
2537
2538         my %buckets2;
2539         my %hash2 = map { ($_->to_org => $_->prox) } @$req_prox;
2540         push( @{$buckets2{ $hash2{$_->{circ_lib}} } }, $_->{id} ) for @$copies;
2541
2542         my $highest_key = $keys[@keys - 1];  # the farthest prox in the exising buckets
2543         my $new_key = $highest_key - 0.5; # right before the farthest prox
2544         my @keys2   = sort { $a <=> $b } keys %buckets2;
2545         for my $key (@keys2) {
2546             last if $key >= $highest_key;
2547             push( @{$buckets{$new_key}}, $_ ) for @{$buckets2{$key}};
2548         }
2549     }
2550
2551     @keys = sort { $a <=> $b } keys %buckets;
2552
2553     my $title;
2554     my %seen;
2555     my @status;
2556     my $age_protect_only = 0;
2557     OUTER: for my $key (@keys) {
2558       my @cps = @{$buckets{$key}};
2559
2560       $logger->info("looking at " . scalar(@{$buckets{$key}}). " copies in proximity bucket $key");
2561
2562       for my $copyid (@cps) {
2563
2564          next if $seen{$copyid};
2565          $seen{$copyid} = 1; # there could be dupes given the merged buckets
2566          my $copy = $e->retrieve_asset_copy($copyid);
2567          $logger->debug("looking at bucket_key=$key, copy $copyid : circ_lib = " . $copy->circ_lib);
2568
2569          unless($title) { # grab the title if we don't already have it
2570             my $vol = $e->retrieve_asset_call_number(
2571                [ $copy->call_number, { flesh => 1, flesh_fields => { bre => ['fixed_fields'], acn => ['record'] } } ] );
2572             $title = $vol->record;
2573          }
2574
2575          @status = verify_copy_for_hold(
2576             $patron, $requestor, $title, $copy, $pickup_lib, $request_lib, $oargs);
2577
2578          $age_protect_only ||= $status[3];
2579          last OUTER if $status[0];
2580       }
2581     }
2582
2583     $status[3] = $age_protect_only;
2584     return @status;
2585 }
2586
2587 sub _check_issuance_hold_is_possible {
2588     my( $issuanceid, $depth, $request_lib, $patron, $requestor, $pickup_lib, $selection_ou, $oargs ) = @_;
2589
2590     my $e = new_editor();
2591     my %org_filter = create_ranged_org_filter($e, $selection_ou, $depth);
2592
2593     # this monster will grab the id and circ_lib of all of the "holdable" copies for the given record
2594     my $copies = $e->json_query(
2595         {
2596             select => { acp => ['id', 'circ_lib'] },
2597               from => {
2598                 acp => {
2599                     sitem => {
2600                         field  => 'unit',
2601                         fkey   => 'id',
2602                         filter => { issuance => $issuanceid }
2603                     },
2604                     acpl => { field => 'id', filter => { holdable => 't'}, fkey => 'location' },
2605                     ccs  => { field => 'id', filter => { holdable => 't'}, fkey => 'status'   }
2606                 }
2607             },
2608             where => {
2609                 '+acp' => { circulate => 't', deleted => 'f', holdable => 't', %org_filter }
2610             },
2611             distinct => 1
2612         }
2613     );
2614
2615     $logger->info("issuance possible found ".scalar(@$copies)." potential copies");
2616
2617     my $empty_ok;
2618     if (!@$copies) {
2619         $empty_ok = $e->retrieve_config_global_flag('circ.holds.empty_issuance_ok');
2620         $empty_ok = ($empty_ok and $U->is_true($empty_ok->enabled));
2621
2622         return (
2623             0, 0, [
2624                 new OpenILS::Event(
2625                     "HIGH_LEVEL_HOLD_HAS_NO_COPIES",
2626                     "payload" => {"fail_part" => "no_ultimate_items"}
2627                 )
2628             ]
2629         ) unless $empty_ok;
2630
2631         return (1, 0);
2632     }
2633
2634     # -----------------------------------------------------------------------
2635     # sort the copies into buckets based on their circ_lib proximity to
2636     # the patron's home_ou.
2637     # -----------------------------------------------------------------------
2638
2639     my $home_org = $patron->home_ou;
2640     my $req_org = $request_lib->id;
2641
2642     $logger->info("prox cache $home_org " . $prox_cache{$home_org});
2643
2644     $prox_cache{$home_org} =
2645         $e->search_actor_org_unit_proximity({from_org => $home_org})
2646         unless $prox_cache{$home_org};
2647     my $home_prox = $prox_cache{$home_org};
2648
2649     my %buckets;
2650     my %hash = map { ($_->to_org => $_->prox) } @$home_prox;
2651     push( @{$buckets{ $hash{$_->{circ_lib}} } }, $_->{id} ) for @$copies;
2652
2653     my @keys = sort { $a <=> $b } keys %buckets;
2654
2655
2656     if( $home_org ne $req_org ) {
2657       # -----------------------------------------------------------------------
2658       # shove the copies close to the request_lib into the primary buckets
2659       # directly before the farthest away copies.  That way, they are not
2660       # given priority, but they are checked before the farthest copies.
2661       # -----------------------------------------------------------------------
2662         $prox_cache{$req_org} =
2663             $e->search_actor_org_unit_proximity({from_org => $req_org})
2664             unless $prox_cache{$req_org};
2665         my $req_prox = $prox_cache{$req_org};
2666
2667         my %buckets2;
2668         my %hash2 = map { ($_->to_org => $_->prox) } @$req_prox;
2669         push( @{$buckets2{ $hash2{$_->{circ_lib}} } }, $_->{id} ) for @$copies;
2670
2671         my $highest_key = $keys[@keys - 1];  # the farthest prox in the exising buckets
2672         my $new_key = $highest_key - 0.5; # right before the farthest prox
2673         my @keys2   = sort { $a <=> $b } keys %buckets2;
2674         for my $key (@keys2) {
2675             last if $key >= $highest_key;
2676             push( @{$buckets{$new_key}}, $_ ) for @{$buckets2{$key}};
2677         }
2678     }
2679
2680     @keys = sort { $a <=> $b } keys %buckets;
2681
2682     my $title;
2683     my %seen;
2684     my @status;
2685     my $age_protect_only = 0;
2686     OUTER: for my $key (@keys) {
2687       my @cps = @{$buckets{$key}};
2688
2689       $logger->info("looking at " . scalar(@{$buckets{$key}}). " copies in proximity bucket $key");
2690
2691       for my $copyid (@cps) {
2692
2693          next if $seen{$copyid};
2694          $seen{$copyid} = 1; # there could be dupes given the merged buckets
2695          my $copy = $e->retrieve_asset_copy($copyid);
2696          $logger->debug("looking at bucket_key=$key, copy $copyid : circ_lib = " . $copy->circ_lib);
2697
2698          unless($title) { # grab the title if we don't already have it
2699             my $vol = $e->retrieve_asset_call_number(
2700                [ $copy->call_number, { flesh => 1, flesh_fields => { bre => ['fixed_fields'], acn => ['record'] } } ] );
2701             $title = $vol->record;
2702          }
2703
2704          @status = verify_copy_for_hold(
2705             $patron, $requestor, $title, $copy, $pickup_lib, $request_lib, $oargs);
2706
2707          $age_protect_only ||= $status[3];
2708          last OUTER if $status[0];
2709       }
2710     }
2711
2712     if (!$status[0]) {
2713         if (!defined($empty_ok)) {
2714             $empty_ok = $e->retrieve_config_global_flag('circ.holds.empty_issuance_ok');
2715             $empty_ok = ($empty_ok and $U->is_true($empty_ok->enabled));
2716         }
2717
2718         return (1,0) if ($empty_ok);
2719     }
2720     $status[3] = $age_protect_only;
2721     return @status;
2722 }
2723
2724 sub _check_monopart_hold_is_possible {
2725     my( $partid, $depth, $request_lib, $patron, $requestor, $pickup_lib, $selection_ou, $oargs ) = @_;
2726
2727     my $e = new_editor();
2728     my %org_filter = create_ranged_org_filter($e, $selection_ou, $depth);
2729
2730     # this monster will grab the id and circ_lib of all of the "holdable" copies for the given record
2731     my $copies = $e->json_query(
2732         {
2733             select => { acp => ['id', 'circ_lib'] },
2734               from => {
2735                 acp => {
2736                     acpm => {
2737                         field  => 'target_copy',
2738                         fkey   => 'id',
2739                         filter => { part => $partid }
2740                     },
2741                     acpl => { field => 'id', filter => { holdable => 't'}, fkey => 'location' },
2742                     ccs  => { field => 'id', filter => { holdable => 't'}, fkey => 'status'   }
2743                 }
2744             },
2745             where => {
2746                 '+acp' => { circulate => 't', deleted => 'f', holdable => 't', %org_filter }
2747             },
2748             distinct => 1
2749         }
2750     );
2751
2752     $logger->info("monopart possible found ".scalar(@$copies)." potential copies");
2753
2754     my $empty_ok;
2755     if (!@$copies) {
2756         $empty_ok = $e->retrieve_config_global_flag('circ.holds.empty_part_ok');
2757         $empty_ok = ($empty_ok and $U->is_true($empty_ok->enabled));
2758
2759         return (
2760             0, 0, [
2761                 new OpenILS::Event(
2762                     "HIGH_LEVEL_HOLD_HAS_NO_COPIES",
2763                     "payload" => {"fail_part" => "no_ultimate_items"}
2764                 )
2765             ]
2766         ) unless $empty_ok;
2767
2768         return (1, 0);
2769     }
2770
2771     # -----------------------------------------------------------------------
2772     # sort the copies into buckets based on their circ_lib proximity to
2773     # the patron's home_ou.
2774     # -----------------------------------------------------------------------
2775
2776     my $home_org = $patron->home_ou;
2777     my $req_org = $request_lib->id;
2778
2779     $logger->info("prox cache $home_org " . $prox_cache{$home_org});
2780
2781     $prox_cache{$home_org} =
2782         $e->search_actor_org_unit_proximity({from_org => $home_org})
2783         unless $prox_cache{$home_org};
2784     my $home_prox = $prox_cache{$home_org};
2785
2786     my %buckets;
2787     my %hash = map { ($_->to_org => $_->prox) } @$home_prox;
2788     push( @{$buckets{ $hash{$_->{circ_lib}} } }, $_->{id} ) for @$copies;
2789
2790     my @keys = sort { $a <=> $b } keys %buckets;
2791
2792
2793     if( $home_org ne $req_org ) {
2794       # -----------------------------------------------------------------------
2795       # shove the copies close to the request_lib into the primary buckets
2796       # directly before the farthest away copies.  That way, they are not
2797       # given priority, but they are checked before the farthest copies.
2798       # -----------------------------------------------------------------------
2799         $prox_cache{$req_org} =
2800             $e->search_actor_org_unit_proximity({from_org => $req_org})
2801             unless $prox_cache{$req_org};
2802         my $req_prox = $prox_cache{$req_org};
2803
2804         my %buckets2;
2805         my %hash2 = map { ($_->to_org => $_->prox) } @$req_prox;
2806         push( @{$buckets2{ $hash2{$_->{circ_lib}} } }, $_->{id} ) for @$copies;
2807
2808         my $highest_key = $keys[@keys - 1];  # the farthest prox in the exising buckets
2809         my $new_key = $highest_key - 0.5; # right before the farthest prox
2810         my @keys2   = sort { $a <=> $b } keys %buckets2;
2811         for my $key (@keys2) {
2812             last if $key >= $highest_key;
2813             push( @{$buckets{$new_key}}, $_ ) for @{$buckets2{$key}};
2814         }
2815     }
2816
2817     @keys = sort { $a <=> $b } keys %buckets;
2818
2819     my $title;
2820     my %seen;
2821     my @status;
2822     my $age_protect_only = 0;
2823     OUTER: for my $key (@keys) {
2824       my @cps = @{$buckets{$key}};
2825
2826       $logger->info("looking at " . scalar(@{$buckets{$key}}). " copies in proximity bucket $key");
2827
2828       for my $copyid (@cps) {
2829
2830          next if $seen{$copyid};
2831          $seen{$copyid} = 1; # there could be dupes given the merged buckets
2832          my $copy = $e->retrieve_asset_copy($copyid);
2833          $logger->debug("looking at bucket_key=$key, copy $copyid : circ_lib = " . $copy->circ_lib);
2834
2835          unless($title) { # grab the title if we don't already have it
2836             my $vol = $e->retrieve_asset_call_number(
2837                [ $copy->call_number, { flesh => 1, flesh_fields => { bre => ['fixed_fields'], acn => ['record'] } } ] );
2838             $title = $vol->record;
2839          }
2840
2841          @status = verify_copy_for_hold(
2842             $patron, $requestor, $title, $copy, $pickup_lib, $request_lib, $oargs);
2843
2844          $age_protect_only ||= $status[3];
2845          last OUTER if $status[0];
2846       }
2847     }
2848
2849     if (!$status[0]) {
2850         if (!defined($empty_ok)) {
2851             $empty_ok = $e->retrieve_config_global_flag('circ.holds.empty_part_ok');
2852             $empty_ok = ($empty_ok and $U->is_true($empty_ok->enabled));
2853         }
2854
2855         return (1,0) if ($empty_ok);
2856     }
2857     $status[3] = $age_protect_only;
2858     return @status;
2859 }
2860
2861
2862 sub _check_volume_hold_is_possible {
2863     my( $vol, $title, $depth, $request_lib, $patron, $requestor, $pickup_lib, $selection_ou, $oargs ) = @_;
2864     my %org_filter = create_ranged_org_filter(new_editor(), $selection_ou, $depth);
2865     my $copies = new_editor->search_asset_copy({call_number => $vol->id, %org_filter});
2866     $logger->info("checking possibility of volume hold for volume ".$vol->id);
2867
2868     my $filter_copies = [];
2869     for my $copy (@$copies) {
2870         # ignore part-mapped copies for regular volume level holds
2871         push(@$filter_copies, $copy) unless
2872             new_editor->search_asset_copy_part_map({target_copy => $copy->id})->[0];
2873     }
2874     $copies = $filter_copies;
2875
2876     return (
2877         0, 0, [
2878             new OpenILS::Event(
2879                 "HIGH_LEVEL_HOLD_HAS_NO_COPIES",
2880                 "payload" => {"fail_part" => "no_ultimate_items"}
2881             )
2882         ]
2883     ) unless @$copies;
2884
2885     my @status;
2886     my $age_protect_only = 0;
2887     for my $copy ( @$copies ) {
2888         @status = verify_copy_for_hold(
2889             $patron, $requestor, $title, $copy, $pickup_lib, $request_lib, $oargs );
2890         $age_protect_only ||= $status[3];
2891         last if $status[0];
2892     }
2893     $status[3] = $age_protect_only;
2894     return @status;
2895 }
2896
2897
2898
2899 sub verify_copy_for_hold {
2900     my( $patron, $requestor, $title, $copy, $pickup_lib, $request_lib, $oargs ) = @_;
2901     # $oargs should be undef unless we're overriding.
2902     $logger->info("checking possibility of copy in hold request for copy ".$copy->id);
2903     my $permitted = OpenILS::Utils::PermitHold::permit_copy_hold(
2904         {
2905             patron           => $patron,
2906             requestor        => $requestor,
2907             copy             => $copy,
2908             title            => $title,
2909             title_descriptor => $title->fixed_fields,
2910             pickup_lib       => $pickup_lib,
2911             request_lib      => $request_lib,
2912             new_hold         => 1,
2913             show_event_list  => 1
2914         }
2915     );
2916
2917     # Check for override permissions on events.
2918     if ($oargs && $permitted && scalar @$permitted) {
2919         # Remove the events from permitted that we can override.
2920         if ($oargs->{events}) {
2921             foreach my $evt (@{$oargs->{events}}) {
2922                 $permitted = [grep {$_->{textcode} ne $evt} @{$permitted}];
2923             }
2924         }
2925         # Now, we handle the override all case by checking remaining
2926         # events against override permissions.
2927         if (scalar @$permitted && $oargs->{all}) {
2928             # Pre-set events and failed members of oargs to empty
2929             # arrays, if they are not set, yet.
2930             $oargs->{events} = [] unless ($oargs->{events});
2931             $oargs->{failed} = [] unless ($oargs->{failed});
2932             # When we're done with these checks, we swap permitted
2933             # with a reference to @disallowed.
2934             my @disallowed = ();
2935             foreach my $evt (@{$permitted}) {
2936                 # Check if we've already seen the event in this
2937                 # session and it failed.
2938                 if (grep {$_ eq $evt->{textcode}} @{$oargs->{failed}}) {
2939                     push(@disallowed, $evt);
2940                 } else {
2941                     # We have to check if the requestor has the
2942                     # override permission.
2943
2944                     # AppUtils::check_user_perms returns the perm if
2945                     # the user doesn't have it, undef if they do.
2946                     if ($apputils->check_user_perms($requestor->id, $requestor->ws_ou, $evt->{textcode} . '.override')) {
2947                         push(@disallowed, $evt);
2948                         push(@{$oargs->{failed}}, $evt->{textcode});
2949                     } else {
2950                         push(@{$oargs->{events}}, $evt->{textcode});
2951                     }
2952                 }
2953             }
2954             $permitted = \@disallowed;
2955         }
2956     }
2957
2958     my $age_protect_only = 0;
2959     if (@$permitted == 1 && @$permitted[0]->{textcode} eq 'ITEM_AGE_PROTECTED') {
2960         $age_protect_only = 1;
2961     }
2962
2963     return (
2964         (not scalar @$permitted), # true if permitted is an empty arrayref
2965         (   # XXX This test is of very dubious value; someone should figure
2966             # out what if anything is checking this value
2967             ($copy->circ_lib == $pickup_lib) and
2968             ($copy->status == OILS_COPY_STATUS_AVAILABLE)
2969         ),
2970         $permitted,
2971         $age_protect_only
2972     );
2973 }
2974
2975
2976
2977 sub find_nearest_permitted_hold {
2978
2979     my $class  = shift;
2980     my $editor = shift;     # CStoreEditor object
2981     my $copy   = shift;     # copy to target
2982     my $user   = shift;     # staff
2983     my $check_only = shift; # do no updates, just see if the copy could fulfill a hold
2984
2985     my $evt = OpenILS::Event->new('ACTION_HOLD_REQUEST_NOT_FOUND');
2986
2987     my $bc = $copy->barcode;
2988
2989     # find any existing holds that already target this copy
2990     my $old_holds = $editor->search_action_hold_request(
2991         {    current_copy => $copy->id,
2992             cancel_time  => undef,
2993             capture_time => undef
2994         }
2995     );
2996
2997     my $hold_stall_interval = $U->ou_ancestor_setting_value($user->ws_ou, OILS_SETTING_HOLD_SOFT_STALL);
2998
2999     $logger->info("circulator: searching for best hold at org ".$user->ws_ou.
3000         " and copy $bc with a hold stalling interval of ". ($hold_stall_interval || "(none)"));
3001
3002     my $fifo = $U->ou_ancestor_setting_value($user->ws_ou, 'circ.holds_fifo');
3003
3004         # search for what should be the best holds for this copy to fulfill
3005         my $best_holds = $U->storagereq(
3006         "open-ils.storage.action.hold_request.nearest_hold.atomic", 
3007                 $user->ws_ou, $copy, 100, $hold_stall_interval, $fifo );
3008
3009     # Add any pre-targeted holds to the list too? Unless they are already there, anyway.
3010     if ($old_holds) {
3011         for my $holdid (@$old_holds) {
3012             next unless $holdid;
3013             push(@$best_holds, $holdid) unless ( grep { ''.$holdid eq ''.$_ } @$best_holds );
3014         }
3015     }
3016
3017     unless(@$best_holds) {
3018         $logger->info("circulator: no suitable holds found for copy $bc");
3019         return (undef, $evt);
3020     }
3021
3022
3023     my $best_hold;
3024
3025     # for each potential hold, we have to run the permit script
3026     # to make sure the hold is actually permitted.
3027     my %reqr_cache;
3028     my %org_cache;
3029     for my $holdid (@$best_holds) {
3030         next unless $holdid;
3031         $logger->info("circulator: checking if hold $holdid is permitted for copy $bc");
3032
3033         my $hold = $editor->retrieve_action_hold_request($holdid) or next;
3034         my $reqr = $reqr_cache{$hold->requestor} || $editor->retrieve_actor_user($hold->requestor);
3035         my $rlib = $org_cache{$hold->request_lib} || $editor->retrieve_actor_org_unit($hold->request_lib);
3036
3037         $reqr_cache{$hold->requestor} = $reqr;
3038         $org_cache{$hold->request_lib} = $rlib;
3039
3040         # see if this hold is permitted
3041         my $permitted = OpenILS::Utils::PermitHold::permit_copy_hold(
3042             {
3043                 patron_id   => $hold->usr,
3044                 requestor   => $reqr,
3045                 copy        => $copy,
3046                 pickup_lib  => $hold->pickup_lib,
3047                 request_lib => $rlib,
3048                 retarget    => 1
3049             }
3050         );
3051
3052         if( $permitted ) {
3053             $best_hold = $hold;
3054             last;
3055         }
3056     }
3057
3058
3059     unless( $best_hold ) { # no "good" permitted holds were found
3060         # we got nuthin
3061         $logger->info("circulator: no suitable holds found for copy $bc");
3062         return (undef, $evt);
3063     }
3064
3065     $logger->info("circulator: best hold ".$best_hold->id." found for copy $bc");
3066
3067     # indicate a permitted hold was found
3068     return $best_hold if $check_only;
3069
3070     # we've found a permitted hold.  we need to "grab" the copy
3071     # to prevent re-targeted holds (next part) from re-grabbing the copy
3072     $best_hold->current_copy($copy->id);
3073     $editor->update_action_hold_request($best_hold)
3074         or return (undef, $editor->event);
3075
3076
3077     my @retarget;
3078
3079     # re-target any other holds that already target this copy
3080     for my $old_hold (@$old_holds) {
3081         next if $old_hold->id eq $best_hold->id; # don't re-target the hold we want
3082         $logger->info("circulator: clearing current_copy and prev_check_time on hold ".
3083             $old_hold->id." after a better hold [".$best_hold->id."] was found");
3084         $old_hold->clear_current_copy;
3085         $old_hold->clear_prev_check_time;
3086         $editor->update_action_hold_request($old_hold)
3087             or return (undef, $editor->event);
3088         push(@retarget, $old_hold->id);
3089     }
3090
3091     return ($best_hold, undef, (@retarget) ? \@retarget : undef);
3092 }
3093
3094
3095
3096
3097
3098
3099 __PACKAGE__->register_method(
3100     method   => 'all_rec_holds',
3101     api_name => 'open-ils.circ.holds.retrieve_all_from_title',
3102 );
3103
3104 sub all_rec_holds {
3105     my( $self, $conn, $auth, $title_id, $args ) = @_;
3106
3107