fc43e1d89a0778a2a5205ae42724d742960fd1d8
[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                 capture_time => undef,
1383                 cancel_time => undef,
1384                 '-or' => [
1385                     {expire_time => undef },
1386                     {expire_time => {'>' => 'now'}}
1387                 ]
1388            }
1389         });
1390     }
1391
1392
1393     my $qpos = 1;
1394     for my $h (@$q_holds) {
1395         last if $h->{id} == $hold->id;
1396         $qpos++;
1397     }
1398
1399     my $hold_data = $e->json_query({
1400         select => {
1401             acp => [ {column => 'id', transform => 'count', aggregate => 1, alias => 'count'} ],
1402             ccm => [ {column =>'avg_wait_time'} ]
1403         },
1404         from => {
1405             ahcm => {
1406                 acp => {
1407                     join => {
1408                         ccm => {type => 'left'}
1409                     }
1410                 }
1411             }
1412         },
1413         where => {'+ahcm' => {hold => $hold->id} }
1414     });
1415
1416     my $user_org = $e->json_query({select => {au => ['home_ou']}, from => 'au', where => {id => $hold->usr}})->[0]->{home_ou};
1417
1418     my $default_wait = $U->ou_ancestor_setting_value($user_org, OILS_SETTING_HOLD_ESIMATE_WAIT_INTERVAL);
1419     my $min_wait = $U->ou_ancestor_setting_value($user_org, 'circ.holds.min_estimated_wait_interval');
1420     $min_wait = OpenSRF::Utils::interval_to_seconds($min_wait || '0 seconds');
1421     $default_wait ||= '0 seconds';
1422
1423     # Estimated wait time is the average wait time across the set
1424     # of potential copies, divided by the number of potential copies
1425     # times the queue position.
1426
1427     my $combined_secs = 0;
1428     my $num_potentials = 0;
1429
1430     for my $wait_data (@$hold_data) {
1431         my $count += $wait_data->{count};
1432         $combined_secs += $count *
1433             OpenSRF::Utils::interval_to_seconds($wait_data->{avg_wait_time} || $default_wait);
1434         $num_potentials += $count;
1435     }
1436
1437     my $estimated_wait = -1;
1438
1439     if($num_potentials) {
1440         my $avg_wait = $combined_secs / $num_potentials;
1441         $estimated_wait = $qpos * ($avg_wait / $num_potentials);
1442         $estimated_wait = $min_wait if $estimated_wait < $min_wait and $estimated_wait != -1;
1443     }
1444
1445     return {
1446         total_holds      => scalar(@$q_holds),
1447         queue_position   => $qpos,
1448         potential_copies => $num_potentials,
1449         status           => _hold_status( $e, $hold ),
1450         estimated_wait   => int($estimated_wait)
1451     };
1452 }
1453
1454
1455 sub fetch_open_hold_by_current_copy {
1456     my $class = shift;
1457     my $copyid = shift;
1458     my $hold = $apputils->simplereq(
1459         'open-ils.cstore',
1460         'open-ils.cstore.direct.action.hold_request.search.atomic',
1461         { current_copy =>  $copyid , cancel_time => undef, fulfillment_time => undef });
1462     return $hold->[0] if ref($hold);
1463     return undef;
1464 }
1465
1466 sub fetch_related_holds {
1467     my $class = shift;
1468     my $copyid = shift;
1469     return $apputils->simplereq(
1470         'open-ils.cstore',
1471         'open-ils.cstore.direct.action.hold_request.search.atomic',
1472         { current_copy =>  $copyid , cancel_time => undef, fulfillment_time => undef });
1473 }
1474
1475
1476 __PACKAGE__->register_method(
1477     method    => "hold_pull_list",
1478     api_name  => "open-ils.circ.hold_pull_list.retrieve",
1479     signature => {
1480         desc   => 'Returns (reference to) a list of holds that need to be "pulled" by a given location. ' .
1481                   'The location is determined by the login session.',
1482         params => [
1483             { desc => 'Limit (optional)',  type => 'number'},
1484             { desc => 'Offset (optional)', type => 'number'},
1485         ],
1486         return => {
1487             desc => 'reference to a list of holds, or event on failure',
1488         }
1489     }
1490 );
1491
1492 __PACKAGE__->register_method(
1493     method    => "hold_pull_list",
1494     api_name  => "open-ils.circ.hold_pull_list.id_list.retrieve",
1495     signature => {
1496         desc   => 'Returns (reference to) a list of holds IDs that need to be "pulled" by a given location. ' .
1497                   'The location is determined by the login session.',
1498         params => [
1499             { desc => 'Limit (optional)',  type => 'number'},
1500             { desc => 'Offset (optional)', type => 'number'},
1501         ],
1502         return => {
1503             desc => 'reference to a list of holds, or event on failure',
1504         }
1505     }
1506 );
1507
1508 __PACKAGE__->register_method(
1509     method    => "hold_pull_list",
1510     api_name  => "open-ils.circ.hold_pull_list.retrieve.count",
1511     signature => {
1512         desc   => 'Returns a count of holds that need to be "pulled" by a given location. ' .
1513                   'The location is determined by the login session.',
1514         params => [
1515             { desc => 'Limit (optional)',  type => 'number'},
1516             { desc => 'Offset (optional)', type => 'number'},
1517         ],
1518         return => {
1519             desc => 'Holds count (integer), or event on failure',
1520             # type => 'number'
1521         }
1522     }
1523 );
1524
1525
1526 sub hold_pull_list {
1527     my( $self, $conn, $authtoken, $limit, $offset ) = @_;
1528     my( $reqr, $evt ) = $U->checkses($authtoken);
1529     return $evt if $evt;
1530
1531     my $org = $reqr->ws_ou || $reqr->home_ou;
1532     # the perm locaiton shouldn't really matter here since holds
1533     # will exist all over and VIEW_HOLDS should be universal
1534     $evt = $U->check_perms($reqr->id, $org, 'VIEW_HOLD');
1535     return $evt if $evt;
1536
1537     if($self->api_name =~ /count/) {
1538
1539         my $count = $U->storagereq(
1540             'open-ils.storage.direct.action.hold_request.pull_list.current_copy_circ_lib.status_filtered.count',
1541             $org, $limit, $offset );
1542
1543         $logger->info("Grabbing pull list for org unit $org with $count items");
1544         return $count;
1545
1546     } elsif( $self->api_name =~ /id_list/ ) {
1547         return $U->storagereq(
1548             'open-ils.storage.direct.action.hold_request.pull_list.id_list.current_copy_circ_lib.status_filtered.atomic',
1549             $org, $limit, $offset );
1550
1551     } else {
1552         return $U->storagereq(
1553             'open-ils.storage.direct.action.hold_request.pull_list.search.current_copy_circ_lib.status_filtered.atomic',
1554             $org, $limit, $offset );
1555     }
1556 }
1557
1558 __PACKAGE__->register_method(
1559     method    => "print_hold_pull_list",
1560     api_name  => "open-ils.circ.hold_pull_list.print",
1561     signature => {
1562         desc   => 'Returns an HTML-formatted holds pull list',
1563         params => [
1564             { desc => 'Authtoken', type => 'string'},
1565             { desc => 'Org unit ID.  Optional, defaults to workstation org unit', type => 'number'},
1566         ],
1567         return => {
1568             desc => 'HTML string',
1569             type => 'string'
1570         }
1571     }
1572 );
1573
1574 sub print_hold_pull_list {
1575     my($self, $client, $auth, $org_id) = @_;
1576
1577     my $e = new_editor(authtoken=>$auth);
1578     return $e->event unless $e->checkauth;
1579
1580     $org_id = (defined $org_id) ? $org_id : $e->requestor->ws_ou;
1581     return $e->event unless $e->allowed('VIEW_HOLD', $org_id);
1582
1583     my $hold_ids = $U->storagereq(
1584         'open-ils.storage.direct.action.hold_request.pull_list.id_list.current_copy_circ_lib.status_filtered.atomic',
1585         $org_id, 10000);
1586
1587     return undef unless @$hold_ids;
1588
1589     $client->status(new OpenSRF::DomainObject::oilsContinueStatus);
1590
1591     # Holds will /NOT/ be in order after this ...
1592     my $holds = $e->search_action_hold_request({id => $hold_ids}, {substream => 1});
1593     $client->status(new OpenSRF::DomainObject::oilsContinueStatus);
1594
1595     # ... so we must resort.
1596     my $hold_map = +{map { $_->id => $_ } @$holds};
1597     my $sorted_holds = [];
1598     push @$sorted_holds, $hold_map->{$_} foreach @$hold_ids;
1599
1600     return $U->fire_object_event(
1601         undef, "ahr.format.pull_list", $sorted_holds,
1602         $org_id, undef, undef, $client
1603     );
1604
1605 }
1606
1607 __PACKAGE__->register_method(
1608     method    => "print_hold_pull_list_stream",
1609     stream   => 1,
1610     api_name  => "open-ils.circ.hold_pull_list.print.stream",
1611     signature => {
1612         desc   => 'Returns a stream of fleshed holds',
1613         params => [
1614             { desc => 'Authtoken', type => 'string'},
1615             { 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)',
1616               type => 'object'
1617             },
1618         ],
1619         return => {
1620             desc => 'A stream of fleshed holds',
1621             type => 'object'
1622         }
1623     }
1624 );
1625
1626 sub print_hold_pull_list_stream {
1627     my($self, $client, $auth, $params) = @_;
1628
1629     my $e = new_editor(authtoken=>$auth);
1630     return $e->die_event unless $e->checkauth;
1631
1632     delete($$params{org_id}) unless (int($$params{org_id}));
1633     delete($$params{limit}) unless (int($$params{limit}));
1634     delete($$params{offset}) unless (int($$params{offset}));
1635     delete($$params{chunk_size}) unless (int($$params{chunk_size}));
1636     delete($$params{chunk_size}) if  ($$params{chunk_size} && $$params{chunk_size} > 50); # keep the size reasonable
1637     $$params{chunk_size} ||= 10;
1638
1639     $$params{org_id} = (defined $$params{org_id}) ? $$params{org_id}: $e->requestor->ws_ou;
1640     return $e->die_event unless $e->allowed('VIEW_HOLD', $$params{org_id });
1641
1642     my $sort = [];
1643     if ($$params{sort} && @{ $$params{sort} }) {
1644         for my $s (@{ $$params{sort} }) {
1645             if ($s eq 'acplo.position') {
1646                 push @$sort, {
1647                     "class" => "acplo", "field" => "position",
1648                     "transform" => "coalesce", "params" => [999]
1649                 };
1650             } elsif ($s eq 'prefix') {
1651                 push @$sort, {"class" => "acnp", "field" => "label_sortkey"};
1652             } elsif ($s eq 'call_number') {
1653                 push @$sort, {"class" => "acn", "field" => "label_sortkey"};
1654             } elsif ($s eq 'suffix') {
1655                 push @$sort, {"class" => "acns", "field" => "label_sortkey"};
1656             } elsif ($s eq 'request_time') {
1657                 push @$sort, {"class" => "ahr", "field" => "request_time"};
1658             }
1659         }
1660     } else {
1661         push @$sort, {"class" => "ahr", "field" => "request_time"};
1662     }
1663
1664     my $holds_ids = $e->json_query(
1665         {
1666             "select" => {"ahr" => ["id"]},
1667             "from" => {
1668                 "ahr" => {
1669                     "acp" => {
1670                         "field" => "id",
1671                         "fkey" => "current_copy",
1672                         "filter" => {
1673                             "circ_lib" => $$params{org_id}, "status" => [0,7]
1674                         },
1675                         "join" => {
1676                             "acn" => {
1677                                 "field" => "id",
1678                                 "fkey" => "call_number",
1679                                 "join" => {
1680                                     "acnp" => {
1681                                         "field" => "id",
1682                                         "fkey" => "prefix"
1683                                     },
1684                                     "acns" => {
1685                                         "field" => "id",
1686                                         "fkey" => "suffix"
1687                                     }
1688                                 }
1689                             },
1690                             "acplo" => {
1691                                 "field" => "org",
1692                                 "fkey" => "circ_lib",
1693                                 "type" => "left",
1694                                 "filter" => {
1695                                     "location" => {"=" => {"+acp" => "location"}}
1696                                 }
1697                             }
1698                         }
1699                     }
1700                 }
1701             },
1702             "where" => {
1703                 "+ahr" => {
1704                     "capture_time" => undef,
1705                     "cancel_time" => undef,
1706                     "-or" => [
1707                         {"expire_time" => undef },
1708                         {"expire_time" => {">" => "now"}}
1709                     ]
1710                 }
1711             },
1712             (@$sort ? (order_by => $sort) : ()),
1713             ($$params{limit} ? (limit => $$params{limit}) : ()),
1714             ($$params{offset} ? (offset => $$params{offset}) : ())
1715         }, {"substream" => 1}
1716     ) or return $e->die_event;
1717
1718     $logger->info("about to stream back " . scalar(@$holds_ids) . " holds");
1719
1720     my @chunk;
1721     for my $hid (@$holds_ids) {
1722         push @chunk, $e->retrieve_action_hold_request([
1723             $hid->{"id"}, {
1724                 "flesh" => 3,
1725                 "flesh_fields" => {
1726                     "ahr" => ["usr", "current_copy"],
1727                     "au"  => ["card"],
1728                     "acp" => ["location", "call_number", "parts"],
1729                     "acn" => ["record","prefix","suffix"]
1730                 }
1731             }
1732         ]);
1733
1734         if (@chunk >= $$params{chunk_size}) {
1735             $client->respond( \@chunk );
1736             @chunk = ();
1737         }
1738     }
1739     $client->respond_complete( \@chunk ) if (@chunk);
1740     $e->disconnect;
1741     return undef;
1742 }
1743
1744
1745
1746 __PACKAGE__->register_method(
1747     method        => 'fetch_hold_notify',
1748     api_name      => 'open-ils.circ.hold_notification.retrieve_by_hold',
1749     authoritative => 1,
1750     signature     => q/
1751 Returns a list of hold notification objects based on hold id.
1752 @param authtoken The loggin session key
1753 @param holdid The id of the hold whose notifications we want to retrieve
1754 @return An array of hold notification objects, event on error.
1755 /
1756 );
1757
1758 sub fetch_hold_notify {
1759     my( $self, $conn, $authtoken, $holdid ) = @_;
1760     my( $requestor, $evt ) = $U->checkses($authtoken);
1761     return $evt if $evt;
1762     my ($hold, $patron);
1763     ($hold, $evt) = $U->fetch_hold($holdid);
1764     return $evt if $evt;
1765     ($patron, $evt) = $U->fetch_user($hold->usr);
1766     return $evt if $evt;
1767
1768     $evt = $U->check_perms($requestor->id, $patron->home_ou, 'VIEW_HOLD_NOTIFICATION');
1769     return $evt if $evt;
1770
1771     $logger->info("User ".$requestor->id." fetching hold notifications for hold $holdid");
1772     return $U->cstorereq(
1773         'open-ils.cstore.direct.action.hold_notification.search.atomic', {hold => $holdid} );
1774 }
1775
1776
1777 __PACKAGE__->register_method(
1778     method    => 'create_hold_notify',
1779     api_name  => 'open-ils.circ.hold_notification.create',
1780     signature => q/
1781 Creates a new hold notification object
1782 @param authtoken The login session key
1783 @param notification The hold notification object to create
1784 @return ID of the new object on success, Event on error
1785 /
1786 );
1787
1788 sub create_hold_notify {
1789    my( $self, $conn, $auth, $note ) = @_;
1790    my $e = new_editor(authtoken=>$auth, xact=>1);
1791    return $e->die_event unless $e->checkauth;
1792
1793    my $hold = $e->retrieve_action_hold_request($note->hold)
1794       or return $e->die_event;
1795    my $patron = $e->retrieve_actor_user($hold->usr)
1796       or return $e->die_event;
1797
1798    return $e->die_event unless
1799       $e->allowed('CREATE_HOLD_NOTIFICATION', $patron->home_ou);
1800
1801    $note->notify_staff($e->requestor->id);
1802    $e->create_action_hold_notification($note) or return $e->die_event;
1803    $e->commit;
1804    return $note->id;
1805 }
1806
1807 __PACKAGE__->register_method(
1808     method    => 'create_hold_note',
1809     api_name  => 'open-ils.circ.hold_note.create',
1810     signature => q/
1811         Creates a new hold request note object
1812         @param authtoken The login session key
1813         @param note The hold note object to create
1814         @return ID of the new object on success, Event on error
1815         /
1816 );
1817
1818 sub create_hold_note {
1819    my( $self, $conn, $auth, $note ) = @_;
1820    my $e = new_editor(authtoken=>$auth, xact=>1);
1821    return $e->die_event unless $e->checkauth;
1822
1823    my $hold = $e->retrieve_action_hold_request($note->hold)
1824       or return $e->die_event;
1825    my $patron = $e->retrieve_actor_user($hold->usr)
1826       or return $e->die_event;
1827
1828    return $e->die_event unless
1829       $e->allowed('UPDATE_HOLD', $patron->home_ou); # FIXME: Using permcrud perm listed in fm_IDL.xml for ahrn.  Probably want something more specific
1830
1831    $e->create_action_hold_request_note($note) or return $e->die_event;
1832    $e->commit;
1833    return $note->id;
1834 }
1835
1836 __PACKAGE__->register_method(
1837     method    => 'reset_hold',
1838     api_name  => 'open-ils.circ.hold.reset',
1839     signature => q/
1840         Un-captures and un-targets a hold, essentially returning
1841         it to the state it was in directly after it was placed,
1842         then attempts to re-target the hold
1843         @param authtoken The login session key
1844         @param holdid The id of the hold
1845     /
1846 );
1847
1848
1849 sub reset_hold {
1850     my( $self, $conn, $auth, $holdid ) = @_;
1851     my $reqr;
1852     my ($hold, $evt) = $U->fetch_hold($holdid);
1853     return $evt if $evt;
1854     ($reqr, $evt) = $U->checksesperm($auth, 'UPDATE_HOLD');
1855     return $evt if $evt;
1856     $evt = _reset_hold($self, $reqr, $hold);
1857     return $evt if $evt;
1858     return 1;
1859 }
1860
1861
1862 __PACKAGE__->register_method(
1863     method   => 'reset_hold_batch',
1864     api_name => 'open-ils.circ.hold.reset.batch'
1865 );
1866
1867 sub reset_hold_batch {
1868     my($self, $conn, $auth, $hold_ids) = @_;
1869
1870     my $e = new_editor(authtoken => $auth);
1871     return $e->event unless $e->checkauth;
1872
1873     for my $hold_id ($hold_ids) {
1874
1875         my $hold = $e->retrieve_action_hold_request(
1876             [$hold_id, {flesh => 1, flesh_fields => {ahr => ['usr']}}])
1877             or return $e->event;
1878
1879         next unless $e->allowed('UPDATE_HOLD', $hold->usr->home_ou);
1880         _reset_hold($self, $e->requestor, $hold);
1881     }
1882
1883     return 1;
1884 }
1885
1886
1887 sub _reset_hold {
1888     my ($self, $reqr, $hold) = @_;
1889
1890     my $e = new_editor(xact =>1, requestor => $reqr);
1891
1892     $logger->info("reseting hold ".$hold->id);
1893
1894     my $hid = $hold->id;
1895
1896     if( $hold->capture_time and $hold->current_copy ) {
1897
1898         my $copy = $e->retrieve_asset_copy($hold->current_copy)
1899             or return $e->die_event;
1900
1901         if( $copy->status == OILS_COPY_STATUS_ON_HOLDS_SHELF ) {
1902             $logger->info("setting copy to status 'reshelving' on hold retarget");
1903             $copy->status(OILS_COPY_STATUS_RESHELVING);
1904             $copy->editor($e->requestor->id);
1905             $copy->edit_date('now');
1906             $e->update_asset_copy($copy) or return $e->die_event;
1907
1908         } elsif( $copy->status == OILS_COPY_STATUS_IN_TRANSIT ) {
1909
1910             # We don't want the copy to remain "in transit"
1911             $copy->status(OILS_COPY_STATUS_RESHELVING);
1912             $logger->warn("! reseting hold [$hid] that is in transit");
1913             my $transid = $e->search_action_hold_transit_copy({hold=>$hold->id},{idlist=>1})->[0];
1914
1915             if( $transid ) {
1916                 my $trans = $e->retrieve_action_transit_copy($transid);
1917                 if( $trans ) {
1918                     $logger->info("Aborting transit [$transid] on hold [$hid] reset...");
1919                     my $evt = OpenILS::Application::Circ::Transit::__abort_transit($e, $trans, $copy, 1, 1);
1920                     $logger->info("Transit abort completed with result $evt");
1921                     unless ("$evt" eq 1) {
1922                         $e->rollback;
1923                         return $evt;
1924                     }
1925                 }
1926             }
1927         }
1928     }
1929
1930     $hold->clear_capture_time;
1931     $hold->clear_current_copy;
1932     $hold->clear_shelf_time;
1933     $hold->clear_shelf_expire_time;
1934     $hold->clear_current_shelf_lib;
1935
1936     $e->update_action_hold_request($hold) or return $e->die_event;
1937     $e->commit;
1938
1939     $U->storagereq(
1940         'open-ils.storage.action.hold_request.copy_targeter', undef, $hold->id );
1941
1942     return undef;
1943 }
1944
1945
1946 __PACKAGE__->register_method(
1947     method    => 'fetch_open_title_holds',
1948     api_name  => 'open-ils.circ.open_holds.retrieve',
1949     signature => q/
1950         Returns a list ids of un-fulfilled holds for a given title id
1951         @param authtoken The login session key
1952         @param id the id of the item whose holds we want to retrieve
1953         @param type The hold type - M, T, I, V, C, F, R
1954     /
1955 );
1956
1957 sub fetch_open_title_holds {
1958     my( $self, $conn, $auth, $id, $type, $org ) = @_;
1959     my $e = new_editor( authtoken => $auth );
1960     return $e->event unless $e->checkauth;
1961
1962     $type ||= "T";
1963     $org  ||= $e->requestor->ws_ou;
1964
1965 #    return $e->search_action_hold_request(
1966 #        { target => $id, hold_type => $type, fulfillment_time => undef }, {idlist=>1});
1967
1968     # XXX make me return IDs in the future ^--
1969     my $holds = $e->search_action_hold_request(
1970         {
1971             target           => $id,
1972             cancel_time      => undef,
1973             hold_type        => $type,
1974             fulfillment_time => undef
1975         }
1976     );
1977
1978     flesh_hold_transits($holds);
1979     return $holds;
1980 }
1981
1982
1983 sub flesh_hold_transits {
1984     my $holds = shift;
1985     for my $hold ( @$holds ) {
1986         $hold->transit(
1987             $apputils->simplereq(
1988                 'open-ils.cstore',
1989                 "open-ils.cstore.direct.action.hold_transit_copy.search.atomic",
1990                 { hold => $hold->id },
1991                 { order_by => { ahtc => 'id desc' }, limit => 1 }
1992             )->[0]
1993         );
1994     }
1995 }
1996
1997 sub flesh_hold_notices {
1998     my( $holds, $e ) = @_;
1999     $e ||= new_editor();
2000
2001     for my $hold (@$holds) {
2002         my $notices = $e->search_action_hold_notification(
2003             [
2004                 { hold => $hold->id },
2005                 { order_by => { anh => 'notify_time desc' } },
2006             ],
2007             {idlist=>1}
2008         );
2009
2010         $hold->notify_count(scalar(@$notices));
2011         if( @$notices ) {
2012             my $n = $e->retrieve_action_hold_notification($$notices[0])
2013                 or return $e->event;
2014             $hold->notify_time($n->notify_time);
2015         }
2016     }
2017 }
2018
2019
2020 __PACKAGE__->register_method(
2021     method    => 'fetch_captured_holds',
2022     api_name  => 'open-ils.circ.captured_holds.on_shelf.retrieve',
2023     stream    => 1,
2024     authoritative => 1,
2025     signature => q/
2026         Returns a list of un-fulfilled holds (on the Holds Shelf) for a given title id
2027         @param authtoken The login session key
2028         @param org The org id of the location in question
2029         @param match_copy A specific copy to limit to
2030     /
2031 );
2032
2033 __PACKAGE__->register_method(
2034     method    => 'fetch_captured_holds',
2035     api_name  => 'open-ils.circ.captured_holds.id_list.on_shelf.retrieve',
2036     stream    => 1,
2037     authoritative => 1,
2038     signature => q/
2039         Returns list ids of un-fulfilled holds (on the Holds Shelf) for a given title id
2040         @param authtoken The login session key
2041         @param org The org id of the location in question
2042         @param match_copy A specific copy to limit to
2043     /
2044 );
2045
2046 __PACKAGE__->register_method(
2047     method    => 'fetch_captured_holds',
2048     api_name  => 'open-ils.circ.captured_holds.id_list.expired_on_shelf.retrieve',
2049     stream    => 1,
2050     authoritative => 1,
2051     signature => q/
2052         Returns list ids of shelf-expired un-fulfilled holds for a given title id
2053         @param authtoken The login session key
2054         @param org The org id of the location in question
2055         @param match_copy A specific copy to limit to
2056     /
2057 );
2058
2059
2060 sub fetch_captured_holds {
2061     my( $self, $conn, $auth, $org, $match_copy ) = @_;
2062
2063     my $e = new_editor(authtoken => $auth);
2064     return $e->die_event unless $e->checkauth;
2065     return $e->die_event unless $e->allowed('VIEW_HOLD'); # XXX rely on editor perm
2066
2067     $org ||= $e->requestor->ws_ou;
2068
2069     my $current_copy = { '!=' => undef };
2070     $current_copy = { '=' => $match_copy } if $match_copy;
2071
2072     my $query = {
2073         select => { alhr => ['id'] },
2074         from   => {
2075             alhr => {
2076                 acp => {
2077                     field => 'id',
2078                     fkey  => 'current_copy'
2079                 },
2080             }
2081         },
2082         where => {
2083             '+acp' => { status => OILS_COPY_STATUS_ON_HOLDS_SHELF, deleted => 'f' },
2084             '+alhr' => {
2085                 capture_time      => { "!=" => undef },
2086                 current_copy      => $current_copy,
2087                 fulfillment_time  => undef,
2088                 current_shelf_lib => $org
2089             }
2090         }
2091     };
2092     if($self->api_name =~ /expired/) {
2093         $query->{'where'}->{'+alhr'}->{'-or'} = {
2094                 shelf_expire_time => { '<' => 'now'},
2095                 cancel_time => { '!=' => undef },
2096         };
2097     }
2098     my $hold_ids = $e->json_query( $query );
2099
2100     for my $hold_id (@$hold_ids) {
2101         if($self->api_name =~ /id_list/) {
2102             $conn->respond($hold_id->{id});
2103             next;
2104         } else {
2105             $conn->respond(
2106                 $e->retrieve_action_hold_request([
2107                     $hold_id->{id},
2108                     {
2109                         flesh => 1,
2110                         flesh_fields => {ahr => ['notifications', 'transit', 'notes']},
2111                         order_by => {anh => 'notify_time desc'}
2112                     }
2113                 ])
2114             );
2115         }
2116     }
2117
2118     return undef;
2119 }
2120
2121 __PACKAGE__->register_method(
2122     method    => "print_expired_holds_stream",
2123     api_name  => "open-ils.circ.captured_holds.expired.print.stream",
2124     stream    => 1
2125 );
2126
2127 sub print_expired_holds_stream {
2128     my ($self, $client, $auth, $params) = @_;
2129
2130     # No need to check specific permissions: we're going to call another method
2131     # that will do that.
2132     my $e = new_editor("authtoken" => $auth);
2133     return $e->die_event unless $e->checkauth;
2134
2135     delete($$params{org_id}) unless (int($$params{org_id}));
2136     delete($$params{limit}) unless (int($$params{limit}));
2137     delete($$params{offset}) unless (int($$params{offset}));
2138     delete($$params{chunk_size}) unless (int($$params{chunk_size}));
2139     delete($$params{chunk_size}) if  ($$params{chunk_size} && $$params{chunk_size} > 50); # keep the size reasonable
2140     $$params{chunk_size} ||= 10;
2141
2142     $$params{org_id} = (defined $$params{org_id}) ? $$params{org_id}: $e->requestor->ws_ou;
2143
2144     my @hold_ids = $self->method_lookup(
2145         "open-ils.circ.captured_holds.id_list.expired_on_shelf.retrieve"
2146     )->run($auth, $params->{"org_id"});
2147
2148     if (!@hold_ids) {
2149         $e->disconnect;
2150         return;
2151     } elsif (defined $U->event_code($hold_ids[0])) {
2152         $e->disconnect;
2153         return $hold_ids[0];
2154     }
2155
2156     $logger->info("about to stream back up to " . scalar(@hold_ids) . " expired holds");
2157
2158     while (@hold_ids) {
2159         my @hid_chunk = splice @hold_ids, 0, $params->{"chunk_size"};
2160
2161         my $result_chunk = $e->json_query({
2162             "select" => {
2163                 "acp" => ["barcode"],
2164                 "au" => [qw/
2165                     first_given_name second_given_name family_name alias
2166                 /],
2167                 "acn" => ["label"],
2168                 "bre" => ["marc"],
2169                 "acpl" => ["name"]
2170             },
2171             "from" => {
2172                 "ahr" => {
2173                     "acp" => {
2174                         "field" => "id", "fkey" => "current_copy",
2175                         "join" => {
2176                             "acn" => {
2177                                 "field" => "id", "fkey" => "call_number",
2178                                 "join" => {
2179                                     "bre" => {
2180                                         "field" => "id", "fkey" => "record"
2181                                     }
2182                                 }
2183                             },
2184                             "acpl" => {"field" => "id", "fkey" => "location"}
2185                         }
2186                     },
2187                     "au" => {"field" => "id", "fkey" => "usr"}
2188                 }
2189             },
2190             "where" => {"+ahr" => {"id" => \@hid_chunk}}
2191         }) or return $e->die_event;
2192         $client->respond($result_chunk);
2193     }
2194
2195     $e->disconnect;
2196     undef;
2197 }
2198
2199 __PACKAGE__->register_method(
2200     method    => "check_title_hold_batch",
2201     api_name  => "open-ils.circ.title_hold.is_possible.batch",
2202     stream    => 1,
2203     signature => {
2204         desc  => '@see open-ils.circ.title_hold.is_possible.batch',
2205         params => [
2206             { desc => 'Authentication token',     type => 'string'},
2207             { desc => 'Array of Hash of named parameters', type => 'array'},
2208         ],
2209         return => {
2210             desc => 'Array of response objects',
2211             type => 'array'
2212         }
2213     }
2214 );
2215
2216 sub check_title_hold_batch {
2217     my($self, $client, $authtoken, $param_list, $oargs) = @_;
2218     foreach (@$param_list) {
2219         my ($res) = $self->method_lookup('open-ils.circ.title_hold.is_possible')->run($authtoken, $_, $oargs);
2220         $client->respond($res);
2221     }
2222     return undef;
2223 }
2224
2225
2226 __PACKAGE__->register_method(
2227     method    => "check_title_hold",
2228     api_name  => "open-ils.circ.title_hold.is_possible",
2229     signature => {
2230         desc  => 'Determines if a hold were to be placed by a given user, ' .
2231              'whether or not said hold would have any potential copies to fulfill it.' .
2232              'The named paramaters of the second argument include: ' .
2233              'patronid, titleid, volume_id, copy_id, mrid, depth, pickup_lib, hold_type, selection_ou. ' .
2234              'See perldoc ' . __PACKAGE__ . ' for more info on these fields.' ,
2235         params => [
2236             { desc => 'Authentication token',     type => 'string'},
2237             { desc => 'Hash of named parameters', type => 'object'},
2238         ],
2239         return => {
2240             desc => 'List of new message IDs (empty if none)',
2241             type => 'array'
2242         }
2243     }
2244 );
2245
2246 =head3 check_title_hold (token, hash)
2247
2248 The named fields in the hash are:
2249
2250  patronid     - ID of the hold recipient  (required)
2251  depth        - hold range depth          (default 0)
2252  pickup_lib   - destination for hold, fallback value for selection_ou
2253  selection_ou - ID of org_unit establishing hard and soft hold boundary settings
2254  issuanceid   - ID of the issuance to be held, required for Issuance level hold
2255  partid       - ID of the monograph part to be held, required for monograph part level hold
2256  titleid      - ID (BRN) of the title to be held, required for Title level hold
2257  volume_id    - required for Volume level hold
2258  copy_id      - required for Copy level hold
2259  mrid         - required for Meta-record level hold
2260  hold_type    - T, C (or R or F), I, V or M for Title, Copy, Issuance, Volume or Meta-record  (default "T")
2261
2262 All key/value pairs are passed on to do_possibility_checks.
2263
2264 =cut
2265
2266 # FIXME: better params checking.  what other params are required, if any?
2267 # FIXME: 3 copies of values confusing: $x, $params->{x} and $params{x}
2268 # FIXME: for example, $depth gets a default value, but then $$params{depth} is still
2269 # used in conditionals, where it may be undefined, causing a warning.
2270 # FIXME: specify proper usage/interaction of selection_ou and pickup_lib
2271
2272 sub check_title_hold {
2273     my( $self, $client, $authtoken, $params ) = @_;
2274     my $e = new_editor(authtoken=>$authtoken);
2275     return $e->event unless $e->checkauth;
2276
2277     my %params       = %$params;
2278     my $depth        = $params{depth}        || 0;
2279     my $selection_ou = $params{selection_ou} || $params{pickup_lib};
2280     my $oargs        = $params{oargs}        || {};
2281
2282     if($oargs->{events}) {
2283         @{$oargs->{events}} = grep { $e->allowed($_ . '.override', $e->requestor->ws_ou); } @{$oargs->{events}};
2284     }
2285
2286
2287     my $patron = $e->retrieve_actor_user($params{patronid})
2288         or return $e->event;
2289
2290     if( $e->requestor->id ne $patron->id ) {
2291         return $e->event unless
2292             $e->allowed('VIEW_HOLD_PERMIT', $patron->home_ou);
2293     }
2294
2295     return OpenILS::Event->new('PATRON_BARRED') if $U->is_true($patron->barred);
2296
2297     my $request_lib = $e->retrieve_actor_org_unit($e->requestor->ws_ou)
2298         or return $e->event;
2299
2300     my $soft_boundary = $U->ou_ancestor_setting_value($selection_ou, OILS_SETTING_HOLD_SOFT_BOUNDARY);
2301     my $hard_boundary = $U->ou_ancestor_setting_value($selection_ou, OILS_SETTING_HOLD_HARD_BOUNDARY);
2302
2303     my @status = ();
2304     my $return_depth = $hard_boundary; # default depth to return on success
2305     if(defined $soft_boundary and $depth < $soft_boundary) {
2306         # work up the tree and as soon as we find a potential copy, use that depth
2307         # also, make sure we don't go past the hard boundary if it exists
2308
2309         # our min boundary is the greater of user-specified boundary or hard boundary
2310         my $min_depth = (defined $hard_boundary and $hard_boundary > $depth) ?
2311             $hard_boundary : $depth;
2312
2313         my $depth = $soft_boundary;
2314         while($depth >= $min_depth) {
2315             $logger->info("performing hold possibility check with soft boundary $depth");
2316             @status = do_possibility_checks($e, $patron, $request_lib, $depth, %params);
2317             if ($status[0]) {
2318                 $return_depth = $depth;
2319                 last;
2320             }
2321             $depth--;
2322         }
2323     } elsif(defined $hard_boundary and $depth < $hard_boundary) {
2324         # there is no soft boundary, enforce the hard boundary if it exists
2325         $logger->info("performing hold possibility check with hard boundary $hard_boundary");
2326         @status = do_possibility_checks($e, $patron, $request_lib, $hard_boundary, %params);
2327     } else {
2328         # no boundaries defined, fall back to user specifed boundary or no boundary
2329         $logger->info("performing hold possibility check with no boundary");
2330         @status = do_possibility_checks($e, $patron, $request_lib, $params{depth}, %params);
2331     }
2332
2333     my $place_unfillable = 0;
2334     $place_unfillable = 1 if $e->allowed('PLACE_UNFILLABLE_HOLD', $e->requestor->ws_ou);
2335
2336     if ($status[0]) {
2337         return {
2338             "success" => 1,
2339             "depth" => $return_depth,
2340             "local_avail" => $status[1]
2341         };
2342     } elsif ($status[2]) {
2343         my $n = scalar @{$status[2]};
2344         return {"success" => 0, "last_event" => $status[2]->[$n - 1], "age_protected_copy" => $status[3], "place_unfillable" => $place_unfillable};
2345     } else {
2346         return {"success" => 0, "age_protected_copy" => $status[3], "place_unfillable" => $place_unfillable};
2347     }
2348 }
2349
2350
2351
2352 sub do_possibility_checks {
2353     my($e, $patron, $request_lib, $depth, %params) = @_;
2354
2355     my $issuanceid   = $params{issuanceid}      || "";
2356     my $partid       = $params{partid}      || "";
2357     my $titleid      = $params{titleid}      || "";
2358     my $volid        = $params{volume_id};
2359     my $copyid       = $params{copy_id};
2360     my $mrid         = $params{mrid}         || "";
2361     my $pickup_lib   = $params{pickup_lib};
2362     my $hold_type    = $params{hold_type}    || 'T';
2363     my $selection_ou = $params{selection_ou} || $pickup_lib;
2364     my $holdable_formats = $params{holdable_formats};
2365     my $oargs        = $params{oargs}        || {};
2366
2367
2368     my $copy;
2369     my $volume;
2370     my $title;
2371
2372     if( $hold_type eq OILS_HOLD_TYPE_FORCE || $hold_type eq OILS_HOLD_TYPE_RECALL || $hold_type eq OILS_HOLD_TYPE_COPY ) {
2373
2374         return $e->event unless $copy   = $e->retrieve_asset_copy($copyid);
2375         return $e->event unless $volume = $e->retrieve_asset_call_number($copy->call_number);
2376         return $e->event unless $title  = $e->retrieve_biblio_record_entry($volume->record);
2377
2378         return (1, 1, []) if( $hold_type eq OILS_HOLD_TYPE_RECALL || $hold_type eq OILS_HOLD_TYPE_FORCE);
2379         return verify_copy_for_hold(
2380             $patron, $e->requestor, $title, $copy, $pickup_lib, $request_lib, $oargs
2381         );
2382
2383     } elsif( $hold_type eq OILS_HOLD_TYPE_VOLUME ) {
2384
2385         return $e->event unless $volume = $e->retrieve_asset_call_number($volid);
2386         return $e->event unless $title  = $e->retrieve_biblio_record_entry($volume->record);
2387
2388         return _check_volume_hold_is_possible(
2389             $volume, $title, $depth, $request_lib, $patron, $e->requestor, $pickup_lib, $selection_ou, $oargs
2390         );
2391
2392     } elsif( $hold_type eq OILS_HOLD_TYPE_TITLE ) {
2393
2394         return _check_title_hold_is_possible(
2395             $titleid, $depth, $request_lib, $patron, $e->requestor, $pickup_lib, $selection_ou, undef, $oargs
2396         );
2397
2398     } elsif( $hold_type eq OILS_HOLD_TYPE_ISSUANCE ) {
2399
2400         return _check_issuance_hold_is_possible(
2401             $issuanceid, $depth, $request_lib, $patron, $e->requestor, $pickup_lib, $selection_ou, $oargs
2402         );
2403
2404     } elsif( $hold_type eq OILS_HOLD_TYPE_MONOPART ) {
2405
2406         return _check_monopart_hold_is_possible(
2407             $partid, $depth, $request_lib, $patron, $e->requestor, $pickup_lib, $selection_ou, $oargs
2408         );
2409
2410     } elsif( $hold_type eq OILS_HOLD_TYPE_METARECORD ) {
2411
2412         my $maps = $e->search_metabib_metarecord_source_map({metarecord=>$mrid});
2413         my @recs = map { $_->source } @$maps;
2414         my @status = ();
2415         for my $rec (@recs) {
2416             @status = _check_title_hold_is_possible(
2417                 $rec, $depth, $request_lib, $patron, $e->requestor, $pickup_lib, $selection_ou, $holdable_formats, $oargs
2418             );
2419             last if $status[0];
2420         }
2421         return @status;
2422     }
2423 #   else { Unrecognized hold_type ! }   # FIXME: return error? or 0?
2424 }
2425
2426 my %prox_cache;
2427 sub create_ranged_org_filter {
2428     my($e, $selection_ou, $depth) = @_;
2429
2430     # find the orgs from which this hold may be fulfilled,
2431     # based on the selection_ou and depth
2432
2433     my $top_org = $e->search_actor_org_unit([
2434         {parent_ou => undef},
2435         {flesh=>1, flesh_fields=>{aou=>['ou_type']}}])->[0];
2436     my %org_filter;
2437
2438     return () if $depth == $top_org->ou_type->depth;
2439
2440     my $org_list = $U->storagereq('open-ils.storage.actor.org_unit.descendants.atomic', $selection_ou, $depth);
2441     %org_filter = (circ_lib => []);
2442     push(@{$org_filter{circ_lib}}, $_->id) for @$org_list;
2443
2444     $logger->info("hold org filter at depth $depth and selection_ou ".
2445         "$selection_ou created list of @{$org_filter{circ_lib}}");
2446
2447     return %org_filter;
2448 }
2449
2450
2451 sub _check_title_hold_is_possible {
2452     my( $titleid, $depth, $request_lib, $patron, $requestor, $pickup_lib, $selection_ou, $holdable_formats, $oargs ) = @_;
2453
2454     my ($types, $formats, $lang);
2455     if (defined($holdable_formats)) {
2456         ($types, $formats, $lang) = split '-', $holdable_formats;
2457     }
2458
2459     my $e = new_editor();
2460     my %org_filter = create_ranged_org_filter($e, $selection_ou, $depth);
2461
2462     # this monster will grab the id and circ_lib of all of the "holdable" copies for the given record
2463     my $copies = $e->json_query(
2464         {
2465             select => { acp => ['id', 'circ_lib'] },
2466               from => {
2467                 acp => {
2468                     acn => {
2469                         field  => 'id',
2470                         fkey   => 'call_number',
2471                         'join' => {
2472                             bre => {
2473                                 field  => 'id',
2474                                 filter => { id => $titleid },
2475                                 fkey   => 'record'
2476                             },
2477                             mrd => {
2478                                 field  => 'record',
2479                                 fkey   => 'record',
2480                                 filter => {
2481                                     record => $titleid,
2482                                     ( $types   ? (item_type => [split '', $types])   : () ),
2483                                     ( $formats ? (item_form => [split '', $formats]) : () ),
2484                                     ( $lang    ? (item_lang => $lang)                : () )
2485                                 }
2486                             }
2487                         }
2488                     },
2489                     acpl => { field => 'id', filter => { holdable => 't'}, fkey => 'location' },
2490                     ccs  => { field => 'id', filter => { holdable => 't'}, fkey => 'status'   },
2491                     acpm => { field => 'target_copy', type => 'left' } # ignore part-linked copies
2492                 }
2493             },
2494             where => {
2495                 '+acp' => { circulate => 't', deleted => 'f', holdable => 't', %org_filter },
2496                 '+acpm' => { target_copy => undef } # ignore part-linked copies
2497             }
2498         }
2499     );
2500
2501     $logger->info("title possible found ".scalar(@$copies)." potential copies");
2502     return (
2503         0, 0, [
2504             new OpenILS::Event(
2505                 "HIGH_LEVEL_HOLD_HAS_NO_COPIES",
2506                 "payload" => {"fail_part" => "no_ultimate_items"}
2507             )
2508         ]
2509     ) unless @$copies;
2510
2511     # -----------------------------------------------------------------------
2512     # sort the copies into buckets based on their circ_lib proximity to
2513     # the patron's home_ou.
2514     # -----------------------------------------------------------------------
2515
2516     my $home_org = $patron->home_ou;
2517     my $req_org = $request_lib->id;
2518
2519     $logger->info("prox cache $home_org " . $prox_cache{$home_org});
2520
2521     $prox_cache{$home_org} =
2522         $e->search_actor_org_unit_proximity({from_org => $home_org})
2523         unless $prox_cache{$home_org};
2524     my $home_prox = $prox_cache{$home_org};
2525
2526     my %buckets;
2527     my %hash = map { ($_->to_org => $_->prox) } @$home_prox;
2528     push( @{$buckets{ $hash{$_->{circ_lib}} } }, $_->{id} ) for @$copies;
2529
2530     my @keys = sort { $a <=> $b } keys %buckets;
2531
2532
2533     if( $home_org ne $req_org ) {
2534       # -----------------------------------------------------------------------
2535       # shove the copies close to the request_lib into the primary buckets
2536       # directly before the farthest away copies.  That way, they are not
2537       # given priority, but they are checked before the farthest copies.
2538       # -----------------------------------------------------------------------
2539         $prox_cache{$req_org} =
2540             $e->search_actor_org_unit_proximity({from_org => $req_org})
2541             unless $prox_cache{$req_org};
2542         my $req_prox = $prox_cache{$req_org};
2543
2544         my %buckets2;
2545         my %hash2 = map { ($_->to_org => $_->prox) } @$req_prox;
2546         push( @{$buckets2{ $hash2{$_->{circ_lib}} } }, $_->{id} ) for @$copies;
2547
2548         my $highest_key = $keys[@keys - 1];  # the farthest prox in the exising buckets
2549         my $new_key = $highest_key - 0.5; # right before the farthest prox
2550         my @keys2   = sort { $a <=> $b } keys %buckets2;
2551         for my $key (@keys2) {
2552             last if $key >= $highest_key;
2553             push( @{$buckets{$new_key}}, $_ ) for @{$buckets2{$key}};
2554         }
2555     }
2556
2557     @keys = sort { $a <=> $b } keys %buckets;
2558
2559     my $title;
2560     my %seen;
2561     my @status;
2562     my $age_protect_only = 0;
2563     OUTER: for my $key (@keys) {
2564       my @cps = @{$buckets{$key}};
2565
2566       $logger->info("looking at " . scalar(@{$buckets{$key}}). " copies in proximity bucket $key");
2567
2568       for my $copyid (@cps) {
2569
2570          next if $seen{$copyid};
2571          $seen{$copyid} = 1; # there could be dupes given the merged buckets
2572          my $copy = $e->retrieve_asset_copy($copyid);
2573          $logger->debug("looking at bucket_key=$key, copy $copyid : circ_lib = " . $copy->circ_lib);
2574
2575          unless($title) { # grab the title if we don't already have it
2576             my $vol = $e->retrieve_asset_call_number(
2577                [ $copy->call_number, { flesh => 1, flesh_fields => { bre => ['fixed_fields'], acn => ['record'] } } ] );
2578             $title = $vol->record;
2579          }
2580
2581          @status = verify_copy_for_hold(
2582             $patron, $requestor, $title, $copy, $pickup_lib, $request_lib, $oargs);
2583
2584          $age_protect_only ||= $status[3];
2585          last OUTER if $status[0];
2586       }
2587     }
2588
2589     $status[3] = $age_protect_only;
2590     return @status;
2591 }
2592
2593 sub _check_issuance_hold_is_possible {
2594     my( $issuanceid, $depth, $request_lib, $patron, $requestor, $pickup_lib, $selection_ou, $oargs ) = @_;
2595
2596     my $e = new_editor();
2597     my %org_filter = create_ranged_org_filter($e, $selection_ou, $depth);
2598
2599     # this monster will grab the id and circ_lib of all of the "holdable" copies for the given record
2600     my $copies = $e->json_query(
2601         {
2602             select => { acp => ['id', 'circ_lib'] },
2603               from => {
2604                 acp => {
2605                     sitem => {
2606                         field  => 'unit',
2607                         fkey   => 'id',
2608                         filter => { issuance => $issuanceid }
2609                     },
2610                     acpl => { field => 'id', filter => { holdable => 't'}, fkey => 'location' },
2611                     ccs  => { field => 'id', filter => { holdable => 't'}, fkey => 'status'   }
2612                 }
2613             },
2614             where => {
2615                 '+acp' => { circulate => 't', deleted => 'f', holdable => 't', %org_filter }
2616             },
2617             distinct => 1
2618         }
2619     );
2620
2621     $logger->info("issuance possible found ".scalar(@$copies)." potential copies");
2622
2623     my $empty_ok;
2624     if (!@$copies) {
2625         $empty_ok = $e->retrieve_config_global_flag('circ.holds.empty_issuance_ok');
2626         $empty_ok = ($empty_ok and $U->is_true($empty_ok->enabled));
2627
2628         return (
2629             0, 0, [
2630                 new OpenILS::Event(
2631                     "HIGH_LEVEL_HOLD_HAS_NO_COPIES",
2632                     "payload" => {"fail_part" => "no_ultimate_items"}
2633                 )
2634             ]
2635         ) unless $empty_ok;
2636
2637         return (1, 0);
2638     }
2639
2640     # -----------------------------------------------------------------------
2641     # sort the copies into buckets based on their circ_lib proximity to
2642     # the patron's home_ou.
2643     # -----------------------------------------------------------------------
2644
2645     my $home_org = $patron->home_ou;
2646     my $req_org = $request_lib->id;
2647
2648     $logger->info("prox cache $home_org " . $prox_cache{$home_org});
2649
2650     $prox_cache{$home_org} =
2651         $e->search_actor_org_unit_proximity({from_org => $home_org})
2652         unless $prox_cache{$home_org};
2653     my $home_prox = $prox_cache{$home_org};
2654
2655     my %buckets;
2656     my %hash = map { ($_->to_org => $_->prox) } @$home_prox;
2657     push( @{$buckets{ $hash{$_->{circ_lib}} } }, $_->{id} ) for @$copies;
2658
2659     my @keys = sort { $a <=> $b } keys %buckets;
2660
2661
2662     if( $home_org ne $req_org ) {
2663       # -----------------------------------------------------------------------
2664       # shove the copies close to the request_lib into the primary buckets
2665       # directly before the farthest away copies.  That way, they are not
2666       # given priority, but they are checked before the farthest copies.
2667       # -----------------------------------------------------------------------
2668         $prox_cache{$req_org} =
2669             $e->search_actor_org_unit_proximity({from_org => $req_org})
2670             unless $prox_cache{$req_org};
2671         my $req_prox = $prox_cache{$req_org};
2672
2673         my %buckets2;
2674         my %hash2 = map { ($_->to_org => $_->prox) } @$req_prox;
2675         push( @{$buckets2{ $hash2{$_->{circ_lib}} } }, $_->{id} ) for @$copies;
2676
2677         my $highest_key = $keys[@keys - 1];  # the farthest prox in the exising buckets
2678         my $new_key = $highest_key - 0.5; # right before the farthest prox
2679         my @keys2   = sort { $a <=> $b } keys %buckets2;
2680         for my $key (@keys2) {
2681             last if $key >= $highest_key;
2682             push( @{$buckets{$new_key}}, $_ ) for @{$buckets2{$key}};
2683         }
2684     }
2685
2686     @keys = sort { $a <=> $b } keys %buckets;
2687
2688     my $title;
2689     my %seen;
2690     my @status;
2691     my $age_protect_only = 0;
2692     OUTER: for my $key (@keys) {
2693       my @cps = @{$buckets{$key}};
2694
2695       $logger->info("looking at " . scalar(@{$buckets{$key}}). " copies in proximity bucket $key");
2696
2697       for my $copyid (@cps) {
2698
2699          next if $seen{$copyid};
2700          $seen{$copyid} = 1; # there could be dupes given the merged buckets
2701          my $copy = $e->retrieve_asset_copy($copyid);
2702          $logger->debug("looking at bucket_key=$key, copy $copyid : circ_lib = " . $copy->circ_lib);
2703
2704          unless($title) { # grab the title if we don't already have it
2705             my $vol = $e->retrieve_asset_call_number(
2706                [ $copy->call_number, { flesh => 1, flesh_fields => { bre => ['fixed_fields'], acn => ['record'] } } ] );
2707             $title = $vol->record;
2708          }
2709
2710          @status = verify_copy_for_hold(
2711             $patron, $requestor, $title, $copy, $pickup_lib, $request_lib, $oargs);
2712
2713          $age_protect_only ||= $status[3];
2714          last OUTER if $status[0];
2715       }
2716     }
2717
2718     if (!$status[0]) {
2719         if (!defined($empty_ok)) {
2720             $empty_ok = $e->retrieve_config_global_flag('circ.holds.empty_issuance_ok');
2721             $empty_ok = ($empty_ok and $U->is_true($empty_ok->enabled));
2722         }
2723
2724         return (1,0) if ($empty_ok);
2725     }
2726     $status[3] = $age_protect_only;
2727     return @status;
2728 }
2729
2730 sub _check_monopart_hold_is_possible {
2731     my( $partid, $depth, $request_lib, $patron, $requestor, $pickup_lib, $selection_ou, $oargs ) = @_;
2732
2733     my $e = new_editor();
2734     my %org_filter = create_ranged_org_filter($e, $selection_ou, $depth);
2735
2736     # this monster will grab the id and circ_lib of all of the "holdable" copies for the given record
2737     my $copies = $e->json_query(
2738         {
2739             select => { acp => ['id', 'circ_lib'] },
2740               from => {
2741                 acp => {
2742                     acpm => {
2743                         field  => 'target_copy',
2744                         fkey   => 'id',
2745                         filter => { part => $partid }
2746                     },
2747                     acpl => { field => 'id', filter => { holdable => 't'}, fkey => 'location' },
2748                     ccs  => { field => 'id', filter => { holdable => 't'}, fkey => 'status'   }
2749                 }
2750             },
2751             where => {
2752                 '+acp' => { circulate => 't', deleted => 'f', holdable => 't', %org_filter }
2753             },
2754             distinct => 1
2755         }
2756     );
2757
2758     $logger->info("monopart possible found ".scalar(@$copies)." potential copies");
2759
2760     my $empty_ok;
2761     if (!@$copies) {
2762         $empty_ok = $e->retrieve_config_global_flag('circ.holds.empty_part_ok');
2763         $empty_ok = ($empty_ok and $U->is_true($empty_ok->enabled));
2764
2765         return (
2766             0, 0, [
2767                 new OpenILS::Event(
2768                     "HIGH_LEVEL_HOLD_HAS_NO_COPIES",
2769                     "payload" => {"fail_part" => "no_ultimate_items"}
2770                 )
2771             ]
2772         ) unless $empty_ok;
2773
2774         return (1, 0);
2775     }
2776
2777     # -----------------------------------------------------------------------
2778     # sort the copies into buckets based on their circ_lib proximity to
2779     # the patron's home_ou.
2780     # -----------------------------------------------------------------------
2781
2782     my $home_org = $patron->home_ou;
2783     my $req_org = $request_lib->id;
2784
2785     $logger->info("prox cache $home_org " . $prox_cache{$home_org});
2786
2787     $prox_cache{$home_org} =
2788         $e->search_actor_org_unit_proximity({from_org => $home_org})
2789         unless $prox_cache{$home_org};
2790     my $home_prox = $prox_cache{$home_org};
2791
2792     my %buckets;
2793     my %hash = map { ($_->to_org => $_->prox) } @$home_prox;
2794     push( @{$buckets{ $hash{$_->{circ_lib}} } }, $_->{id} ) for @$copies;
2795
2796     my @keys = sort { $a <=> $b } keys %buckets;
2797
2798
2799     if( $home_org ne $req_org ) {
2800       # -----------------------------------------------------------------------
2801       # shove the copies close to the request_lib into the primary buckets
2802       # directly before the farthest away copies.  That way, they are not
2803       # given priority, but they are checked before the farthest copies.
2804       # -----------------------------------------------------------------------
2805         $prox_cache{$req_org} =
2806             $e->search_actor_org_unit_proximity({from_org => $req_org})
2807             unless $prox_cache{$req_org};
2808         my $req_prox = $prox_cache{$req_org};
2809
2810         my %buckets2;
2811         my %hash2 = map { ($_->to_org => $_->prox) } @$req_prox;
2812         push( @{$buckets2{ $hash2{$_->{circ_lib}} } }, $_->{id} ) for @$copies;
2813
2814         my $highest_key = $keys[@keys - 1];  # the farthest prox in the exising buckets
2815         my $new_key = $highest_key - 0.5; # right before the farthest prox
2816         my @keys2   = sort { $a <=> $b } keys %buckets2;
2817         for my $key (@keys2) {
2818             last if $key >= $highest_key;
2819             push( @{$buckets{$new_key}}, $_ ) for @{$buckets2{$key}};
2820         }
2821     }
2822
2823     @keys = sort { $a <=> $b } keys %buckets;
2824
2825     my $title;
2826     my %seen;
2827     my @status;
2828     my $age_protect_only = 0;
2829     OUTER: for my $key (@keys) {
2830       my @cps = @{$buckets{$key}};
2831
2832       $logger->info("looking at " . scalar(@{$buckets{$key}}). " copies in proximity bucket $key");
2833
2834       for my $copyid (@cps) {
2835
2836          next if $seen{$copyid};
2837          $seen{$copyid} = 1; # there could be dupes given the merged buckets
2838          my $copy = $e->retrieve_asset_copy($copyid);
2839          $logger->debug("looking at bucket_key=$key, copy $copyid : circ_lib = " . $copy->circ_lib);
2840
2841          unless($title) { # grab the title if we don't already have it
2842             my $vol = $e->retrieve_asset_call_number(
2843                [ $copy->call_number, { flesh => 1, flesh_fields => { bre => ['fixed_fields'], acn => ['record'] } } ] );
2844             $title = $vol->record;
2845          }
2846
2847          @status = verify_copy_for_hold(
2848             $patron, $requestor, $title, $copy, $pickup_lib, $request_lib, $oargs);
2849
2850          $age_protect_only ||= $status[3];
2851          last OUTER if $status[0];
2852       }
2853     }
2854
2855     if (!$status[0]) {
2856         if (!defined($empty_ok)) {
2857             $empty_ok = $e->retrieve_config_global_flag('circ.holds.empty_part_ok');
2858             $empty_ok = ($empty_ok and $U->is_true($empty_ok->enabled));
2859         }
2860
2861         return (1,0) if ($empty_ok);
2862     }
2863     $status[3] = $age_protect_only;
2864     return @status;
2865 }
2866
2867
2868 sub _check_volume_hold_is_possible {
2869     my( $vol, $title, $depth, $request_lib, $patron, $requestor, $pickup_lib, $selection_ou, $oargs ) = @_;
2870     my %org_filter = create_ranged_org_filter(new_editor(), $selection_ou, $depth);
2871     my $copies = new_editor->search_asset_copy({call_number => $vol->id, %org_filter});
2872     $logger->info("checking possibility of volume hold for volume ".$vol->id);
2873
2874     my $filter_copies = [];
2875     for my $copy (@$copies) {
2876         # ignore part-mapped copies for regular volume level holds
2877         push(@$filter_copies, $copy) unless
2878             new_editor->search_asset_copy_part_map({target_copy => $copy->id})->[0];
2879     }
2880     $copies = $filter_copies;
2881
2882     return (
2883         0, 0, [
2884             new OpenILS::Event(
2885                 "HIGH_LEVEL_HOLD_HAS_NO_COPIES",
2886                 "payload" => {"fail_part" => "no_ultimate_items"}
2887             )
2888         ]
2889     ) unless @$copies;
2890
2891     my @status;
2892     my $age_protect_only = 0;
2893     for my $copy ( @$copies ) {
2894         @status = verify_copy_for_hold(
2895             $patron, $requestor, $title, $copy, $pickup_lib, $request_lib, $oargs );
2896         $age_protect_only ||= $status[3];
2897         last if $status[0];
2898     }
2899     $status[3] = $age_protect_only;
2900     return @status;
2901 }
2902
2903
2904
2905 sub verify_copy_for_hold {
2906     my( $patron, $requestor, $title, $copy, $pickup_lib, $request_lib, $oargs ) = @_;
2907     # $oargs should be undef unless we're overriding.
2908     $logger->info("checking possibility of copy in hold request for copy ".$copy->id);
2909     my $permitted = OpenILS::Utils::PermitHold::permit_copy_hold(
2910         {
2911             patron           => $patron,
2912             requestor        => $requestor,
2913             copy             => $copy,
2914             title            => $title,
2915             title_descriptor => $title->fixed_fields,
2916             pickup_lib       => $pickup_lib,
2917             request_lib      => $request_lib,
2918             new_hold         => 1,
2919             show_event_list  => 1
2920         }
2921     );
2922
2923     # Check for override permissions on events.
2924     if ($oargs && $permitted && scalar @$permitted) {
2925         # Remove the events from permitted that we can override.
2926         if ($oargs->{events}) {
2927             foreach my $evt (@{$oargs->{events}}) {
2928                 $permitted = [grep {$_->{textcode} ne $evt} @{$permitted}];
2929             }
2930         }
2931         # Now, we handle the override all case by checking remaining
2932         # events against override permissions.
2933         if (scalar @$permitted && $oargs->{all}) {
2934             # Pre-set events and failed members of oargs to empty
2935             # arrays, if they are not set, yet.
2936             $oargs->{events} = [] unless ($oargs->{events});
2937             $oargs->{failed} = [] unless ($oargs->{failed});
2938             # When we're done with these checks, we swap permitted
2939             # with a reference to @disallowed.
2940             my @disallowed = ();
2941             foreach my $evt (@{$permitted}) {
2942                 # Check if we've already seen the event in this
2943                 # session and it failed.
2944                 if (grep {$_ eq $evt->{textcode}} @{$oargs->{failed}}) {
2945                     push(@disallowed, $evt);
2946                 } else {
2947                     # We have to check if the requestor has the
2948                     # override permission.
2949
2950                     # AppUtils::check_user_perms returns the perm if
2951                     # the user doesn't have it, undef if they do.
2952                     if ($apputils->check_user_perms($requestor->id, $requestor->ws_ou, $evt->{textcode} . '.override')) {
2953                         push(@disallowed, $evt);
2954                         push(@{$oargs->{failed}}, $evt->{textcode});
2955                     } else {
2956                         push(@{$oargs->{events}}, $evt->{textcode});
2957                     }
2958                 }
2959             }
2960             $permitted = \@disallowed;
2961         }
2962     }
2963
2964     my $age_protect_only = 0;
2965     if (@$permitted == 1 && @$permitted[0]->{textcode} eq 'ITEM_AGE_PROTECTED') {
2966         $age_protect_only = 1;
2967     }
2968
2969     return (
2970         (not scalar @$permitted), # true if permitted is an empty arrayref
2971         (   # XXX This test is of very dubious value; someone should figure
2972             # out what if anything is checking this value
2973             ($copy->circ_lib == $pickup_lib) and
2974             ($copy->status == OILS_COPY_STATUS_AVAILABLE)
2975         ),
2976         $permitted,
2977         $age_protect_only
2978     );
2979 }
2980
2981
2982
2983 sub find_nearest_permitted_hold {
2984
2985     my $class  = shift;
2986     my $editor = shift;     # CStoreEditor object
2987     my $copy   = shift;     # copy to target
2988     my $user   = shift;     # staff
2989     my $check_only = shift; # do no updates, just see if the copy could fulfill a hold
2990
2991     my $evt = OpenILS::Event->new('ACTION_HOLD_REQUEST_NOT_FOUND');
2992
2993     my $bc = $copy->barcode;
2994
2995     # find any existing holds that already target this copy
2996     my $old_holds = $editor->search_action_hold_request(
2997         {    current_copy => $copy->id,
2998             cancel_time  => undef,
2999             capture_time => undef
3000         }
3001     );
3002
3003     my $hold_stall_interval = $U->ou_ancestor_setting_value($user->ws_ou, OILS_SETTING_HOLD_SOFT_STALL);
3004
3005     $logger->info("circulator: searching for best hold at org ".$user->ws_ou.
3006         " and copy $bc with a hold stalling interval of ". ($hold_stall_interval || "(none)"));
3007
3008     my $fifo = $U->ou_ancestor_setting_value($user->ws_ou, 'circ.holds_fifo');
3009
3010     # the nearest_hold API call now needs this
3011     $copy->call_number($editor->retrieve_asset_call_number($copy->call_number))
3012         unless ref $copy->call_number;
3013
3014         # search for what should be the best holds for this copy to fulfill
3015         my $best_holds = $U->storagereq(
3016         "open-ils.storage.action.hold_request.nearest_hold.atomic", 
3017                 $user->ws_ou, $copy, 100, $hold_stall_interval, $fifo );
3018
3019     # Add any pre-targeted holds to the list too? Unless they are already there, anyway.
3020     if ($old_holds) {
3021         for my $holdid (@$old_holds) {
3022             next unless $holdid;
3023             push(@$best_holds, $holdid) unless ( grep { ''.$holdid eq ''.$_ } @$best_holds );
3024         }
3025     }
3026
3027     unless(@$best_holds) {
3028         $logger->info("circulator: no suitable holds found for copy $bc");
3029         return (undef, $evt);
3030     }
3031
3032
3033     my $best_hold;
3034
3035     # for each potential hold, we have to run the permit script
3036     # to make sure the hold is actually permitted.
3037     my %reqr_cache;
3038     my %org_cache;
3039     for my $holdid (@$best_holds) {
3040         next unless $holdid;
3041         $logger->info("circulator: checking if hold $holdid is permitted for copy $bc");
3042
3043         my $hold = $editor->retrieve_action_hold_request($holdid) or next;
3044         my $reqr = $reqr_cache{$hold->requestor} || $editor->retrieve_actor_user($hold->requestor);
3045         my $rlib = $org_cache{$hold->request_lib} || $editor->retrieve_actor_org_unit($hold->request_lib);
3046
3047         $reqr_cache{$hold->requestor} = $reqr;
3048         $org_cache{$hold->request_lib} = $rlib;
3049
3050         # see if this hold is permitted
3051         my $permitted = OpenILS::Utils::PermitHold::permit_copy_hold(
3052             {
3053                 patron_id   => $hold->usr,
3054                 requestor   => $reqr,
3055                 copy        => $copy,
3056                 pickup_lib  => $hold->pickup_lib,
3057                 request_lib => $rlib,
3058                 retarget    => 1
3059             }
3060         );
3061
3062         if( $permitted ) {
3063             $best_hold = $hold;
3064             last;
3065         }
3066     }
3067
3068
3069     unless( $best_hold ) { # no "good" permitted holds were found
3070         # we got nuthin
3071         $logger->info("circulator: no suitable holds found for copy $bc");
3072         return (undef, $evt);
3073     }
3074
3075     $logger->info("circulator: best hold ".$best_hold->id." found for copy $bc");
3076
3077     # indicate a permitted hold was found
3078     return $best_hold if $check_only;
3079
3080     # we've found a permitted hold.  we need to "grab" the copy
3081     # to prevent re-targeted holds (next part) from re-grabbing the copy
3082     $best_hold->current_copy($copy->id);
3083     $editor->update_action_hold_request($best_hold)
3084         or return (undef, $editor->event);
3085
3086
3087     my @retarget;
3088
3089     # re-target any other holds that already target this copy
3090     for my $old_hold (@$old_holds) {
3091         next if $old_hold->id eq $best_hold->id; # don't re-target the hold we want
3092         $logger->info("circulator: clearing current_copy and prev_check_time on hold ".
3093             $old_hold->id." after a better hold [".$best_hold->id."] was found");
3094         $old_hold->clear_current_copy;
3095         $old_hold->clear_prev_check_time;
3096         $editor->update_action_hold_request($old_hold)
3097             or return (undef, $editor->event);
3098         push(@retarget, $old_hold->id);
3099     }
3100
3101     return ($best_hold, undef, (@retarget) ? \@retarget : undef);
3102 }
3103
3104
3105
3106
3107
3108
3109 __PACKAGE__->register_method(
3110     method   => 'all_rec_holds',
3111     api_name => 'open-ils.circ.holds.retrieve_all_from_title',
3112 );
3113
3114 sub all_rec_holds {
3115     my( $self, $conn, $auth, $title_id, $args ) = @_;
3116
3117     my $e = new_editor(authtoken=>$auth);
3118     $e->checkauth or return $e->event;
3119     $e->allowed('VIEW_HOLD') or return $e->event;
3120
3121     $args ||= {};
3122     $args->{fulfillment_time} = undef; #  we don't want to see old fulfilled holds
3123     $args->{cancel_time} = undef;
3124
3125     my $resp = { volume_holds => [], copy_holds => [], recall_holds => [], force_holds => [], metarecord_holds => [], part_holds => [], issuance_holds => [] };
3126
3127     my $mr_map = $e->search_metabib_metarecord_source_map({source => $title_id})->[0];
3128     if($mr_map) {
3129         $resp->{metarecord_holds} = $e->search_action_hold_request(
3130             {   hold_type => OILS_HOLD_TYPE_METARECORD,
3131                 target => $mr_map->metarecord,
3132                 %$args
3133             }, {idlist => 1}
3134         );
3135     }
3136
3137     $resp->{title_holds} = $e->search_action_hold_request(
3138         {
3139             hold_type => OILS_HOLD_TYPE_TITLE,
3140             target => $title_id,
3141             %$args
3142         }, {idlist=>1} );
3143
3144     my $parts = $e->search_biblio_monograph_part(
3145         {
3146             record => $title_id
3147         }, {idlist=>1} );
3148
3149     if (@$parts) {
3150         $resp->{part_holds} = $e->search_action_hold_request(
3151             {
3152                 hold_type => OILS_HOLD_TYPE_MONOPART,
3153                 target => $parts,
3154                 %$args
3155             }, {idlist=>1} );
3156     }
3157
3158     my $subs = $e->search_serial_subscription(
3159         { record_entry => $title_id }, {idlist=>1});
3160
3161     if (@$subs) {
3162         my $issuances = $e->search_serial_issuance(
3163             {subscription => $subs}, {idlist=>1}
3164         );
3165
3166         if ($issuances) {
3167             $resp->{issuance_holds} = $e->search_action_hold_request(
3168                 {
3169                     hold_type => OILS_HOLD_TYPE_ISSUANCE,
3170                     target => $issuances,
3171                     %$args
3172                 }, {idlist=>1}
3173             );
3174         }
3175     }
3176
3177     my $vols = $e->search_asset_call_number(
3178         { record => $title_id, deleted => 'f' }, {idlist=>1});
3179
3180     return $resp unless @$vols;
3181
3182     $resp->{volume_holds} = $e->search_action_hold_request(
3183         {
3184             hold_type => OILS_HOLD_TYPE_VOLUME,
3185             target => $vols,
3186             %$args },
3187         {idlist=>1} );
3188
3189     my $copies = $e->search_asset_copy(
3190         { call_number => $vols, deleted => 'f' }, {idlist=>1});
3191
3192     return $resp unless @$copies;
3193
3194     $resp->{copy_holds} = $e->search_action_hold_request(
3195         {
3196             hold_type => OILS_HOLD_TYPE_COPY,
3197             target => $copies,
3198             %$args },
3199         {idlist=>1} );
3200
3201     $resp->{recall_holds} = $e->search_action_hold_request(
3202         {
3203             hold_type => OILS_HOLD_TYPE_RECALL,
3204             target => $copies,
3205             %$args },
3206         {idlist=>1} );
3207
3208     $resp->{force_holds} = $e->search_action_hold_request(
3209         {
3210             hold_type => OILS_HOLD_TYPE_FORCE,
3211             target => $copies,
3212             %$args },
3213         {idlist=>1} );
3214
3215     return $resp;
3216 }
3217
3218
3219
3220
3221
3222 __PACKAGE__->register_method(
3223     method        => 'uber_hold',
3224     authoritative => 1,
3225     api_name      => 'open-ils.circ.hold.details.retrieve'
3226 );
3227
3228 sub uber_hold {
3229     my($self, $client, $auth, $hold_id, $args) = @_;
3230     my $e = new_editor(authtoken=>$auth);
3231     $e->checkauth or return $e->event;
3232     return uber_hold_impl($e, $hold_id, $args);
3233 }
3234
3235 __PACKAGE__->register_method(
3236     method        => 'batch_uber_hold',
3237     authoritative => 1,
3238     stream        => 1,
3239     api_name      => 'open-ils.circ.hold.details.batch.retrieve'
3240 );
3241
3242 sub batch_uber_hold {
3243     my($self, $client, $auth, $hold_ids, $args) = @_;
3244     my $e = new_editor(authtoken=>$auth);
3245     $e->checkauth or return $e->event;
3246     $client->respond(uber_hold_impl($e, $_, $args)) for @$hold_ids;
3247     return undef;
3248 }
3249
3250 sub uber_hold_impl {
3251     my($e, $hold_id, $args) = @_;
3252     $args ||= {};
3253
3254     my $hold = $e->retrieve_action_hold_request(
3255         [
3256             $hold_id,
3257             {
3258                 flesh => 1,
3259                 flesh_fields => { ahr => [ 'current_copy', 'usr', 'notes' ] }
3260             }
3261         ]
3262     ) or return $e->event;
3263
3264     if($hold->usr->id ne $e->requestor->id) {
3265         # caller is asking for someone else's hold
3266         $e->allowed('VIEW_HOLD') or return $e->event;
3267         $hold->notes( # filter out any non-staff ("private") notes (unless marked as public)
3268             [ grep { $U->is_true($_->staff) or $U->is_true($_->pub) } @{$hold->notes} ] );
3269
3270     } else {
3271         # caller is asking for own hold, but may not have permission to view staff notes
3272         unless($e->allowed('VIEW_HOLD')) {
3273             $hold->notes( # filter out any staff notes (unless marked as public)
3274                 [ grep { !$U->is_true($_->staff) or $U->is_true($_->pub) } @{$hold->notes} ] );
3275         }
3276     }
3277
3278     my $user = $hold->usr;
3279     $hold->usr($user->id);
3280
3281
3282     my( $mvr, $volume, $copy, $issuance, $part, $bre ) = find_hold_mvr($e, $hold, $args->{suppress_mvr});
3283
3284     flesh_hold_notices([$hold], $e) unless $args->{suppress_notices};
3285     flesh_hold_transits([$hold]) unless $args->{suppress_transits};
3286
3287     my $details = retrieve_hold_queue_status_impl($e, $hold);
3288
3289     my $resp = {
3290         hold    => $hold,
3291         bre_id  => $bre->id,
3292         ($copy     ? (copy           => $copy)     : ()),
3293         ($volume   ? (volume         => $volume)   : ()),
3294         ($issuance ? (issuance       => $issuance) : ()),
3295         ($part     ? (part           => $part)     : ()),
3296         ($args->{include_bre}  ?  (bre => $bre)    : ()),
3297         ($args->{suppress_mvr} ?  () : (mvr => $mvr)),
3298         %$details
3299     };
3300
3301     unless($args->{suppress_patron_details}) {
3302         my $card = $e->retrieve_actor_card($user->card) or return $e->event;
3303         $resp->{patron_first}   = $user->first_given_name,
3304         $resp->{patron_last}    = $user->family_name,
3305         $resp->{patron_barcode} = $card->barcode,
3306         $resp->{patron_alias}   = $user->alias,
3307     };
3308
3309     return $resp;
3310 }
3311
3312
3313
3314 # -----------------------------------------------------
3315 # Returns the MVR object that represents what the
3316 # hold is all about
3317 # -----------------------------------------------------
3318 sub find_hold_mvr {
3319     my( $e, $hold, $no_mvr ) = @_;
3320
3321     my $tid;
3322     my $copy;
3323     my $volume;
3324     my $issuance;
3325     my $part;
3326
3327     if( $hold->hold_type eq OILS_HOLD_TYPE_METARECORD ) {
3328         my $mr = $e->retrieve_metabib_metarecord($hold->target)
3329             or return $e->event;
3330         $tid = $mr->master_record;
3331
3332     } elsif( $hold->hold_type eq OILS_HOLD_TYPE_TITLE ) {
3333         $tid = $hold->target;
3334
3335     } elsif( $hold->hold_type eq OILS_HOLD_TYPE_VOLUME ) {
3336         $volume = $e->retrieve_asset_call_number($hold->target)
3337             or return $e->event;
3338         $tid = $volume->record;
3339
3340     } elsif( $hold->hold_type eq OILS_HOLD_TYPE_ISSUANCE ) {
3341         $issuance = $e->retrieve_serial_issuance([
3342             $hold->target,
3343             {flesh => 1, flesh_fields => {siss => [ qw/subscription/ ]}}
3344         ]) or return $e->event;
3345
3346         $tid = $issuance->subscription->record_entry;
3347
3348     } elsif( $hold->hold_type eq OILS_HOLD_TYPE_MONOPART ) {
3349         $part = $e->retrieve_biblio_monograph_part([
3350             $hold->target
3351         ]) or return $e->event;
3352
3353         $tid = $part->record;
3354
3355     } elsif( $hold->hold_type eq OILS_HOLD_TYPE_COPY || $hold->hold_type eq OILS_HOLD_TYPE_RECALL || $hold->hold_type eq OILS_HOLD_TYPE_FORCE ) {
3356         $copy = $e->retrieve_asset_copy([
3357             $hold->target,
3358             {flesh => 1, flesh_fields => {acp => ['call_number']}}
3359         ]) or return $e->event;
3360
3361         $volume = $copy->call_number;
3362         $tid = $volume->record;
3363     }
3364
3365     if(!$copy and ref $hold->current_copy ) {
3366         $copy = $hold->current_copy;
3367         $hold->current_copy($copy->id);
3368     }
3369
3370     if(!$volume and $copy) {
3371         $volume = $e->retrieve_asset_call_number($copy->call_number);
3372     }
3373
3374     # TODO return metarcord mvr for M holds
3375     my $title = $e->retrieve_biblio_record_entry($tid);
3376     return ( ($no_mvr) ? undef : $U->record_to_mvr($title), $volume, $copy, $issuance, $part, $title );
3377 }
3378
3379 __PACKAGE__->register_method(
3380     method    => 'clear_shelf_cache',
3381     api_name  => 'open-ils.circ.hold.clear_shelf.get_cache',
3382     stream    => 1,
3383     signature => {
3384         desc => q/
3385             Returns the holds processed with the given cache key
3386         /
3387     }
3388 );
3389
3390 sub clear_shelf_cache {
3391     my($self, $client, $auth, $cache_key, $chunk_size) = @_;
3392     my $e = new_editor(authtoken => $auth, xact => 1);
3393     return $e->die_event unless $e->checkauth and $e->allowed('VIEW_HOLD');
3394
3395     $chunk_size ||= 25;
3396     my $hold_data = OpenSRF::Utils::Cache->new('global')->get_cache($cache_key);
3397
3398     if (!$hold_data) {
3399         $logger->info("no hold data found in cache"); # XXX TODO return event
3400         $e->rollback;
3401         return undef;
3402     }
3403
3404     my $maximum = 0;
3405     foreach (keys %$hold_data) {
3406         $maximum += scalar(@{ $hold_data->{$_} });
3407     }
3408     $client->respond({"maximum" => $maximum, "progress" => 0});
3409
3410     for my $action (sort keys %$hold_data) {
3411         while (@{$hold_data->{$action}}) {
3412             my @hid_chunk = splice @{$hold_data->{$action}}, 0, $chunk_size;
3413
3414             my $result_chunk = $e->json_query({
3415                 "select" => {
3416                     "acp" => ["barcode"],
3417                     "au" => [qw/
3418                         first_given_name second_given_name family_name alias
3419                     /],
3420                     "acn" => ["label"],
3421                     "acnp" => [{column => "label", alias => "prefix"}],
3422                     "acns" => [{column => "label", alias => "suffix"}],
3423                     "bre" => ["marc"],
3424                     "acpl" => ["name"],
3425                     "ahr" => ["id"]
3426                 },
3427                 "from" => {
3428                     "ahr" => {
3429                         "acp" => {
3430                             "field" => "id", "fkey" => "current_copy",
3431                             "join" => {
3432                                 "acn" => {
3433                                     "field" => "id", "fkey" => "call_number",
3434                                     "join" => {
3435                                         "bre" => {
3436                                             "field" => "id", "fkey" => "record"
3437                                         },
3438                                         "acnp" => {
3439                                             "field" => "id", "fkey" => "prefix"
3440                                         },
3441                                         "acns" => {
3442                                             "field" => "id", "fkey" => "suffix"
3443                                         }
3444                                     }
3445                                 },
3446                                 "acpl" => {"field" => "id", "fkey" => "location"}
3447                             }
3448                         },
3449                         "au" => {"field" => "id", "fkey" => "usr"}
3450                     }
3451                 },
3452                 "where" => {"+ahr" => {"id" => \@hid_chunk}}
3453             }, {"substream" => 1}) or return $e->die_event;
3454
3455             $client->respond([
3456                 map {
3457                     +{"action" => $action, "hold_details" => $_}
3458                 } @$result_chunk
3459             ]);
3460         }
3461     }
3462
3463     $e->rollback;
3464     return undef;
3465 }
3466
3467
3468 __PACKAGE__->register_method(
3469     method    => 'clear_shelf_process',
3470     stream    => 1,
3471     api_name  => 'open-ils.circ.hold.clear_shelf.process',
3472     signature => {
3473         desc => q/
3474             1. Find all holds that have expired on the holds shelf
3475             2. Cancel the holds
3476             3. If a clear-shelf status is configured, put targeted copies into this status
3477             4. Divide copies into 3 groups: items to transit, items to reshelve, and items
3478                 that are needed for holds.  No subsequent action is taken on the holds
3479                 or items after grouping.
3480         /
3481     }
3482 );
3483
3484 sub clear_shelf_process {
3485     my($self, $client, $auth, $org_id, $match_copy) = @_;
3486
3487     my $e = new_editor(authtoken=>$auth);
3488     $e->checkauth or return $e->die_event;
3489     my $cache = OpenSRF::Utils::Cache->new('global');
3490
3491     $org_id ||= $e->requestor->ws_ou;
3492     $e->allowed('UPDATE_HOLD', $org_id) or return $e->die_event;
3493
3494     my $copy_status = $U->ou_ancestor_setting_value($org_id, 'circ.holds.clear_shelf.copy_status');
3495
3496     my @hold_ids = $self->method_lookup(
3497         "open-ils.circ.captured_holds.id_list.expired_on_shelf.retrieve"
3498     )->run($auth, $org_id, $match_copy);
3499
3500     $e->xact_begin;
3501
3502     my @holds;
3503     my @canceled_holds; # newly canceled holds
3504     my $chunk_size = 25; # chunked status updates
3505     my $counter = 0;
3506     for my $hold_id (@hold_ids) {
3507
3508         $logger->info("Clear shelf processing hold $hold_id");
3509
3510         my $hold = $e->retrieve_action_hold_request([
3511             $hold_id, {
3512                 flesh => 1,
3513                 flesh_fields => {ahr => ['current_copy']}
3514             }
3515         ]);
3516
3517         if (!$hold->cancel_time) { # may be canceled but still on the holds shelf
3518             $hold->cancel_time('now');
3519             $hold->cancel_cause(2); # Hold Shelf expiration
3520             $e->update_action_hold_request($hold) or return $e->die_event;
3521             delete_hold_copy_maps($self, $e, $hold->id) and return $e->die_event;
3522             push(@canceled_holds, $hold_id);
3523         }
3524
3525         my $copy = $hold->current_copy;
3526
3527         if($copy_status or $copy_status == 0) {
3528             # if a clear-shelf copy status is defined, update the copy
3529             $copy->status($copy_status);
3530             $copy->edit_date('now');
3531             $copy->editor($e->requestor->id);
3532             $e->update_asset_copy($copy) or return $e->die_event;
3533         }
3534
3535         push(@holds, $hold);
3536         $client->respond({maximum => scalar(@holds), progress => $counter}) if ( (++$counter % $chunk_size) == 0);
3537     }
3538
3539     if ($e->commit) {
3540
3541         my %cache_data = (
3542             hold => [],
3543             transit => [],
3544             shelf => []
3545         );
3546
3547         for my $hold (@holds) {
3548
3549             my $copy = $hold->current_copy;
3550             my ($alt_hold) = __PACKAGE__->find_nearest_permitted_hold($e, $copy, $e->requestor, 1);
3551
3552             if($alt_hold and !$match_copy) {
3553
3554                 push(@{$cache_data{hold}}, $hold->id); # copy is needed for a hold
3555
3556             } elsif($copy->circ_lib != $e->requestor->ws_ou) {
3557
3558                 push(@{$cache_data{transit}}, $hold->id); # copy needs to transit
3559
3560             } else {
3561
3562                 push(@{$cache_data{shelf}}, $hold->id); # copy needs to go back to the shelf
3563             }
3564         }
3565
3566         my $cache_key = md5_hex(time . $$ . rand());
3567         $logger->info("clear_shelf_cache: storing under $cache_key");
3568         $cache->put_cache($cache_key, \%cache_data, 7200); # TODO: 2 hours.  configurable?
3569
3570         # tell the client we're done
3571         $client->respond_complete({cache_key => $cache_key});
3572
3573         # ------------
3574         # fire off the hold cancelation trigger and wait for response so don't flood the service
3575
3576         # refetch the holds to pick up the caclulated cancel_time,