Make Force/Recall holds always return as nearest
[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} = {'=' => {'+ahr' => '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 __PACKAGE__->register_method(
2060     method    => 'fetch_captured_holds',
2061     api_name  => 
2062       'open-ils.circ.captured_holds.expired_on_shelf_or_wrong_shelf.retrieve',
2063     stream    => 1,
2064     authoritative => 1,
2065     signature => q/
2066         Returns list of shelf-expired un-fulfilled holds OR wrong shelf holds
2067         for a given shelf lib
2068     /
2069 );
2070
2071 __PACKAGE__->register_method(
2072     method    => 'fetch_captured_holds',
2073     api_name  => 
2074       'open-ils.circ.captured_holds.id_list.expired_on_shelf_or_wrong_shelf.retrieve',
2075     stream    => 1,
2076     authoritative => 1,
2077     signature => q/
2078         Returns list of shelf-expired un-fulfilled holds OR wrong shelf holds
2079         for a given shelf lib
2080     /
2081 );
2082
2083
2084 sub fetch_captured_holds {
2085     my( $self, $conn, $auth, $org, $match_copy ) = @_;
2086
2087     my $e = new_editor(authtoken => $auth);
2088     return $e->die_event unless $e->checkauth;
2089     return $e->die_event unless $e->allowed('VIEW_HOLD'); # XXX rely on editor perm
2090
2091     $org ||= $e->requestor->ws_ou;
2092
2093     my $current_copy = { '!=' => undef };
2094     $current_copy = { '=' => $match_copy } if $match_copy;
2095
2096     my $query = {
2097         select => { alhr => ['id'] },
2098         from   => {
2099             alhr => {
2100                 acp => {
2101                     field => 'id',
2102                     fkey  => 'current_copy'
2103                 },
2104             }
2105         },
2106         where => {
2107             '+acp' => { status => OILS_COPY_STATUS_ON_HOLDS_SHELF, deleted => 'f' },
2108             '+alhr' => {
2109                 capture_time      => { "!=" => undef },
2110                 current_copy      => $current_copy,
2111                 fulfillment_time  => undef,
2112                 current_shelf_lib => $org
2113             }
2114         }
2115     };
2116     if($self->api_name =~ /expired/) {
2117         $query->{'where'}->{'+alhr'}->{'-or'} = {
2118                 shelf_expire_time => { '<' => 'now'},
2119                 cancel_time => { '!=' => undef },
2120         };
2121     }
2122     my $hold_ids = $e->json_query( $query );
2123
2124     if ($self->api_name =~ /wrong_shelf/) {
2125         # fetch holds whose current_shelf_lib is $org, but whose pickup 
2126         # lib is some other org unit.  Ignore already-retrieved holds.
2127         my $wrong_shelf =
2128             pickup_lib_changed_on_shelf_holds(
2129                 $e, $org, [map {$_->{id}} @$hold_ids]);
2130         # match the layout of other items in $hold_ids
2131         push (@$hold_ids, {id => $_}) for @$wrong_shelf;
2132     }
2133
2134
2135     for my $hold_id (@$hold_ids) {
2136         if($self->api_name =~ /id_list/) {
2137             $conn->respond($hold_id->{id});
2138             next;
2139         } else {
2140             $conn->respond(
2141                 $e->retrieve_action_hold_request([
2142                     $hold_id->{id},
2143                     {
2144                         flesh => 1,
2145                         flesh_fields => {ahr => ['notifications', 'transit', 'notes']},
2146                         order_by => {anh => 'notify_time desc'}
2147                     }
2148                 ])
2149             );
2150         }
2151     }
2152
2153     return undef;
2154 }
2155
2156 __PACKAGE__->register_method(
2157     method    => "print_expired_holds_stream",
2158     api_name  => "open-ils.circ.captured_holds.expired.print.stream",
2159     stream    => 1
2160 );
2161
2162 sub print_expired_holds_stream {
2163     my ($self, $client, $auth, $params) = @_;
2164
2165     # No need to check specific permissions: we're going to call another method
2166     # that will do that.
2167     my $e = new_editor("authtoken" => $auth);
2168     return $e->die_event unless $e->checkauth;
2169
2170     delete($$params{org_id}) unless (int($$params{org_id}));
2171     delete($$params{limit}) unless (int($$params{limit}));
2172     delete($$params{offset}) unless (int($$params{offset}));
2173     delete($$params{chunk_size}) unless (int($$params{chunk_size}));
2174     delete($$params{chunk_size}) if  ($$params{chunk_size} && $$params{chunk_size} > 50); # keep the size reasonable
2175     $$params{chunk_size} ||= 10;
2176
2177     $$params{org_id} = (defined $$params{org_id}) ? $$params{org_id}: $e->requestor->ws_ou;
2178
2179     my @hold_ids = $self->method_lookup(
2180         "open-ils.circ.captured_holds.id_list.expired_on_shelf.retrieve"
2181     )->run($auth, $params->{"org_id"});
2182
2183     if (!@hold_ids) {
2184         $e->disconnect;
2185         return;
2186     } elsif (defined $U->event_code($hold_ids[0])) {
2187         $e->disconnect;
2188         return $hold_ids[0];
2189     }
2190
2191     $logger->info("about to stream back up to " . scalar(@hold_ids) . " expired holds");
2192
2193     while (@hold_ids) {
2194         my @hid_chunk = splice @hold_ids, 0, $params->{"chunk_size"};
2195
2196         my $result_chunk = $e->json_query({
2197             "select" => {
2198                 "acp" => ["barcode"],
2199                 "au" => [qw/
2200                     first_given_name second_given_name family_name alias
2201                 /],
2202                 "acn" => ["label"],
2203                 "bre" => ["marc"],
2204                 "acpl" => ["name"]
2205             },
2206             "from" => {
2207                 "ahr" => {
2208                     "acp" => {
2209                         "field" => "id", "fkey" => "current_copy",
2210                         "join" => {
2211                             "acn" => {
2212                                 "field" => "id", "fkey" => "call_number",
2213                                 "join" => {
2214                                     "bre" => {
2215                                         "field" => "id", "fkey" => "record"
2216                                     }
2217                                 }
2218                             },
2219                             "acpl" => {"field" => "id", "fkey" => "location"}
2220                         }
2221                     },
2222                     "au" => {"field" => "id", "fkey" => "usr"}
2223                 }
2224             },
2225             "where" => {"+ahr" => {"id" => \@hid_chunk}}
2226         }) or return $e->die_event;
2227         $client->respond($result_chunk);
2228     }
2229
2230     $e->disconnect;
2231     undef;
2232 }
2233
2234 __PACKAGE__->register_method(
2235     method    => "check_title_hold_batch",
2236     api_name  => "open-ils.circ.title_hold.is_possible.batch",
2237     stream    => 1,
2238     signature => {
2239         desc  => '@see open-ils.circ.title_hold.is_possible.batch',
2240         params => [
2241             { desc => 'Authentication token',     type => 'string'},
2242             { desc => 'Array of Hash of named parameters', type => 'array'},
2243         ],
2244         return => {
2245             desc => 'Array of response objects',
2246             type => 'array'
2247         }
2248     }
2249 );
2250
2251 sub check_title_hold_batch {
2252     my($self, $client, $authtoken, $param_list, $oargs) = @_;
2253     foreach (@$param_list) {
2254         my ($res) = $self->method_lookup('open-ils.circ.title_hold.is_possible')->run($authtoken, $_, $oargs);
2255         $client->respond($res);
2256     }
2257     return undef;
2258 }
2259
2260
2261 __PACKAGE__->register_method(
2262     method    => "check_title_hold",
2263     api_name  => "open-ils.circ.title_hold.is_possible",
2264     signature => {
2265         desc  => 'Determines if a hold were to be placed by a given user, ' .
2266              'whether or not said hold would have any potential copies to fulfill it.' .
2267              'The named paramaters of the second argument include: ' .
2268              'patronid, titleid, volume_id, copy_id, mrid, depth, pickup_lib, hold_type, selection_ou. ' .
2269              'See perldoc ' . __PACKAGE__ . ' for more info on these fields.' ,
2270         params => [
2271             { desc => 'Authentication token',     type => 'string'},
2272             { desc => 'Hash of named parameters', type => 'object'},
2273         ],
2274         return => {
2275             desc => 'List of new message IDs (empty if none)',
2276             type => 'array'
2277         }
2278     }
2279 );
2280
2281 =head3 check_title_hold (token, hash)
2282
2283 The named fields in the hash are:
2284
2285  patronid     - ID of the hold recipient  (required)
2286  depth        - hold range depth          (default 0)
2287  pickup_lib   - destination for hold, fallback value for selection_ou
2288  selection_ou - ID of org_unit establishing hard and soft hold boundary settings
2289  issuanceid   - ID of the issuance to be held, required for Issuance level hold
2290  partid       - ID of the monograph part to be held, required for monograph part level hold
2291  titleid      - ID (BRN) of the title to be held, required for Title level hold
2292  volume_id    - required for Volume level hold
2293  copy_id      - required for Copy level hold
2294  mrid         - required for Meta-record level hold
2295  hold_type    - T, C (or R or F), I, V or M for Title, Copy, Issuance, Volume or Meta-record  (default "T")
2296
2297 All key/value pairs are passed on to do_possibility_checks.
2298
2299 =cut
2300
2301 # FIXME: better params checking.  what other params are required, if any?
2302 # FIXME: 3 copies of values confusing: $x, $params->{x} and $params{x}
2303 # FIXME: for example, $depth gets a default value, but then $$params{depth} is still
2304 # used in conditionals, where it may be undefined, causing a warning.
2305 # FIXME: specify proper usage/interaction of selection_ou and pickup_lib
2306
2307 sub check_title_hold {
2308     my( $self, $client, $authtoken, $params ) = @_;
2309     my $e = new_editor(authtoken=>$authtoken);
2310     return $e->event unless $e->checkauth;
2311
2312     my %params       = %$params;
2313     my $depth        = $params{depth}        || 0;
2314     my $selection_ou = $params{selection_ou} || $params{pickup_lib};
2315     my $oargs        = $params{oargs}        || {};
2316
2317     if($oargs->{events}) {
2318         @{$oargs->{events}} = grep { $e->allowed($_ . '.override', $e->requestor->ws_ou); } @{$oargs->{events}};
2319     }
2320
2321
2322     my $patron = $e->retrieve_actor_user($params{patronid})
2323         or return $e->event;
2324
2325     if( $e->requestor->id ne $patron->id ) {
2326         return $e->event unless
2327             $e->allowed('VIEW_HOLD_PERMIT', $patron->home_ou);
2328     }
2329
2330     return OpenILS::Event->new('PATRON_BARRED') if $U->is_true($patron->barred);
2331
2332     my $request_lib = $e->retrieve_actor_org_unit($e->requestor->ws_ou)
2333         or return $e->event;
2334
2335     my $soft_boundary = $U->ou_ancestor_setting_value($selection_ou, OILS_SETTING_HOLD_SOFT_BOUNDARY);
2336     my $hard_boundary = $U->ou_ancestor_setting_value($selection_ou, OILS_SETTING_HOLD_HARD_BOUNDARY);
2337
2338     my @status = ();
2339     my $return_depth = $hard_boundary; # default depth to return on success
2340     if(defined $soft_boundary and $depth < $soft_boundary) {
2341         # work up the tree and as soon as we find a potential copy, use that depth
2342         # also, make sure we don't go past the hard boundary if it exists
2343
2344         # our min boundary is the greater of user-specified boundary or hard boundary
2345         my $min_depth = (defined $hard_boundary and $hard_boundary > $depth) ?
2346             $hard_boundary : $depth;
2347
2348         my $depth = $soft_boundary;
2349         while($depth >= $min_depth) {
2350             $logger->info("performing hold possibility check with soft boundary $depth");
2351             @status = do_possibility_checks($e, $patron, $request_lib, $depth, %params);
2352             if ($status[0]) {
2353                 $return_depth = $depth;
2354                 last;
2355             }
2356             $depth--;
2357         }
2358     } elsif(defined $hard_boundary and $depth < $hard_boundary) {
2359         # there is no soft boundary, enforce the hard boundary if it exists
2360         $logger->info("performing hold possibility check with hard boundary $hard_boundary");
2361         @status = do_possibility_checks($e, $patron, $request_lib, $hard_boundary, %params);
2362     } else {
2363         # no boundaries defined, fall back to user specifed boundary or no boundary
2364         $logger->info("performing hold possibility check with no boundary");
2365         @status = do_possibility_checks($e, $patron, $request_lib, $params{depth}, %params);
2366     }
2367
2368     my $place_unfillable = 0;
2369     $place_unfillable = 1 if $e->allowed('PLACE_UNFILLABLE_HOLD', $e->requestor->ws_ou);
2370
2371     if ($status[0]) {
2372         return {
2373             "success" => 1,
2374             "depth" => $return_depth,
2375             "local_avail" => $status[1]
2376         };
2377     } elsif ($status[2]) {
2378         my $n = scalar @{$status[2]};
2379         return {"success" => 0, "last_event" => $status[2]->[$n - 1], "age_protected_copy" => $status[3], "place_unfillable" => $place_unfillable};
2380     } else {
2381         return {"success" => 0, "age_protected_copy" => $status[3], "place_unfillable" => $place_unfillable};
2382     }
2383 }
2384
2385
2386
2387 sub do_possibility_checks {
2388     my($e, $patron, $request_lib, $depth, %params) = @_;
2389
2390     my $issuanceid   = $params{issuanceid}      || "";
2391     my $partid       = $params{partid}      || "";
2392     my $titleid      = $params{titleid}      || "";
2393     my $volid        = $params{volume_id};
2394     my $copyid       = $params{copy_id};
2395     my $mrid         = $params{mrid}         || "";
2396     my $pickup_lib   = $params{pickup_lib};
2397     my $hold_type    = $params{hold_type}    || 'T';
2398     my $selection_ou = $params{selection_ou} || $pickup_lib;
2399     my $holdable_formats = $params{holdable_formats};
2400     my $oargs        = $params{oargs}        || {};
2401
2402
2403     my $copy;
2404     my $volume;
2405     my $title;
2406
2407     if( $hold_type eq OILS_HOLD_TYPE_FORCE || $hold_type eq OILS_HOLD_TYPE_RECALL || $hold_type eq OILS_HOLD_TYPE_COPY ) {
2408
2409         return $e->event unless $copy   = $e->retrieve_asset_copy($copyid);
2410         return $e->event unless $volume = $e->retrieve_asset_call_number($copy->call_number);
2411         return $e->event unless $title  = $e->retrieve_biblio_record_entry($volume->record);
2412
2413         return (1, 1, []) if( $hold_type eq OILS_HOLD_TYPE_RECALL || $hold_type eq OILS_HOLD_TYPE_FORCE);
2414         return verify_copy_for_hold(
2415             $patron, $e->requestor, $title, $copy, $pickup_lib, $request_lib, $oargs
2416         );
2417
2418     } elsif( $hold_type eq OILS_HOLD_TYPE_VOLUME ) {
2419
2420         return $e->event unless $volume = $e->retrieve_asset_call_number($volid);
2421         return $e->event unless $title  = $e->retrieve_biblio_record_entry($volume->record);
2422
2423         return _check_volume_hold_is_possible(
2424             $volume, $title, $depth, $request_lib, $patron, $e->requestor, $pickup_lib, $selection_ou, $oargs
2425         );
2426
2427     } elsif( $hold_type eq OILS_HOLD_TYPE_TITLE ) {
2428
2429         return _check_title_hold_is_possible(
2430             $titleid, $depth, $request_lib, $patron, $e->requestor, $pickup_lib, $selection_ou, undef, $oargs
2431         );
2432
2433     } elsif( $hold_type eq OILS_HOLD_TYPE_ISSUANCE ) {
2434
2435         return _check_issuance_hold_is_possible(
2436             $issuanceid, $depth, $request_lib, $patron, $e->requestor, $pickup_lib, $selection_ou, $oargs
2437         );
2438
2439     } elsif( $hold_type eq OILS_HOLD_TYPE_MONOPART ) {
2440
2441         return _check_monopart_hold_is_possible(
2442             $partid, $depth, $request_lib, $patron, $e->requestor, $pickup_lib, $selection_ou, $oargs
2443         );
2444
2445     } elsif( $hold_type eq OILS_HOLD_TYPE_METARECORD ) {
2446
2447         my $maps = $e->search_metabib_metarecord_source_map({metarecord=>$mrid});
2448         my @recs = map { $_->source } @$maps;
2449         my @status = ();
2450         for my $rec (@recs) {
2451             @status = _check_title_hold_is_possible(
2452                 $rec, $depth, $request_lib, $patron, $e->requestor, $pickup_lib, $selection_ou, $holdable_formats, $oargs
2453             );
2454             last if $status[0];
2455         }
2456         return @status;
2457     }
2458 #   else { Unrecognized hold_type ! }   # FIXME: return error? or 0?
2459 }
2460
2461 my %prox_cache;
2462 sub create_ranged_org_filter {
2463     my($e, $selection_ou, $depth) = @_;
2464
2465     # find the orgs from which this hold may be fulfilled,
2466     # based on the selection_ou and depth
2467
2468     my $top_org = $e->search_actor_org_unit([
2469         {parent_ou => undef},
2470         {flesh=>1, flesh_fields=>{aou=>['ou_type']}}])->[0];
2471     my %org_filter;
2472
2473     return () if $depth == $top_org->ou_type->depth;
2474
2475     my $org_list = $U->storagereq('open-ils.storage.actor.org_unit.descendants.atomic', $selection_ou, $depth);
2476     %org_filter = (circ_lib => []);
2477     push(@{$org_filter{circ_lib}}, $_->id) for @$org_list;
2478
2479     $logger->info("hold org filter at depth $depth and selection_ou ".
2480         "$selection_ou created list of @{$org_filter{circ_lib}}");
2481
2482     return %org_filter;
2483 }
2484
2485
2486 sub _check_title_hold_is_possible {
2487     my( $titleid, $depth, $request_lib, $patron, $requestor, $pickup_lib, $selection_ou, $holdable_formats, $oargs ) = @_;
2488
2489     my ($types, $formats, $lang);
2490     if (defined($holdable_formats)) {
2491         ($types, $formats, $lang) = split '-', $holdable_formats;
2492     }
2493
2494     my $e = new_editor();
2495     my %org_filter = create_ranged_org_filter($e, $selection_ou, $depth);
2496
2497     # this monster will grab the id and circ_lib of all of the "holdable" copies for the given record
2498     my $copies = $e->json_query(
2499         {
2500             select => { acp => ['id', 'circ_lib'] },
2501               from => {
2502                 acp => {
2503                     acn => {
2504                         field  => 'id',
2505                         fkey   => 'call_number',
2506                         'join' => {
2507                             bre => {
2508                                 field  => 'id',
2509                                 filter => { id => $titleid },
2510                                 fkey   => 'record'
2511                             },
2512                             mrd => {
2513                                 field  => 'record',
2514                                 fkey   => 'record',
2515                                 filter => {
2516                                     record => $titleid,
2517                                     ( $types   ? (item_type => [split '', $types])   : () ),
2518                                     ( $formats ? (item_form => [split '', $formats]) : () ),
2519                                     ( $lang    ? (item_lang => $lang)                : () )
2520                                 }
2521                             }
2522                         }
2523                     },
2524                     acpl => { field => 'id', filter => { holdable => 't'}, fkey => 'location' },
2525                     ccs  => { field => 'id', filter => { holdable => 't'}, fkey => 'status'   },
2526                     acpm => { field => 'target_copy', type => 'left' } # ignore part-linked copies
2527                 }
2528             },
2529             where => {
2530                 '+acp' => { circulate => 't', deleted => 'f', holdable => 't', %org_filter },
2531                 '+acpm' => { target_copy => undef } # ignore part-linked copies
2532             }
2533         }
2534     );
2535
2536     $logger->info("title possible found ".scalar(@$copies)." potential copies");
2537     return (
2538         0, 0, [
2539             new OpenILS::Event(
2540                 "HIGH_LEVEL_HOLD_HAS_NO_COPIES",
2541                 "payload" => {"fail_part" => "no_ultimate_items"}
2542             )
2543         ]
2544     ) unless @$copies;
2545
2546     # -----------------------------------------------------------------------
2547     # sort the copies into buckets based on their circ_lib proximity to
2548     # the patron's home_ou.
2549     # -----------------------------------------------------------------------
2550
2551     my $home_org = $patron->home_ou;
2552     my $req_org = $request_lib->id;
2553
2554     $logger->info("prox cache $home_org " . $prox_cache{$home_org});
2555
2556     $prox_cache{$home_org} =
2557         $e->search_actor_org_unit_proximity({from_org => $home_org})
2558         unless $prox_cache{$home_org};
2559     my $home_prox = $prox_cache{$home_org};
2560
2561     my %buckets;
2562     my %hash = map { ($_->to_org => $_->prox) } @$home_prox;
2563     push( @{$buckets{ $hash{$_->{circ_lib}} } }, $_->{id} ) for @$copies;
2564
2565     my @keys = sort { $a <=> $b } keys %buckets;
2566
2567
2568     if( $home_org ne $req_org ) {
2569       # -----------------------------------------------------------------------
2570       # shove the copies close to the request_lib into the primary buckets
2571       # directly before the farthest away copies.  That way, they are not
2572       # given priority, but they are checked before the farthest copies.
2573       # -----------------------------------------------------------------------
2574         $prox_cache{$req_org} =
2575             $e->search_actor_org_unit_proximity({from_org => $req_org})
2576             unless $prox_cache{$req_org};
2577         my $req_prox = $prox_cache{$req_org};
2578
2579         my %buckets2;
2580         my %hash2 = map { ($_->to_org => $_->prox) } @$req_prox;
2581         push( @{$buckets2{ $hash2{$_->{circ_lib}} } }, $_->{id} ) for @$copies;
2582
2583         my $highest_key = $keys[@keys - 1];  # the farthest prox in the exising buckets
2584         my $new_key = $highest_key - 0.5; # right before the farthest prox
2585         my @keys2   = sort { $a <=> $b } keys %buckets2;
2586         for my $key (@keys2) {
2587             last if $key >= $highest_key;
2588             push( @{$buckets{$new_key}}, $_ ) for @{$buckets2{$key}};
2589         }
2590     }
2591
2592     @keys = sort { $a <=> $b } keys %buckets;
2593
2594     my $title;
2595     my %seen;
2596     my @status;
2597     my $age_protect_only = 0;
2598     OUTER: for my $key (@keys) {
2599       my @cps = @{$buckets{$key}};
2600
2601       $logger->info("looking at " . scalar(@{$buckets{$key}}). " copies in proximity bucket $key");
2602
2603       for my $copyid (@cps) {
2604
2605          next if $seen{$copyid};
2606          $seen{$copyid} = 1; # there could be dupes given the merged buckets
2607          my $copy = $e->retrieve_asset_copy($copyid);
2608          $logger->debug("looking at bucket_key=$key, copy $copyid : circ_lib = " . $copy->circ_lib);
2609
2610          unless($title) { # grab the title if we don't already have it
2611             my $vol = $e->retrieve_asset_call_number(
2612                [ $copy->call_number, { flesh => 1, flesh_fields => { bre => ['fixed_fields'], acn => ['record'] } } ] );
2613             $title = $vol->record;
2614          }
2615
2616          @status = verify_copy_for_hold(
2617             $patron, $requestor, $title, $copy, $pickup_lib, $request_lib, $oargs);
2618
2619          $age_protect_only ||= $status[3];
2620          last OUTER if $status[0];
2621       }
2622     }
2623
2624     $status[3] = $age_protect_only;
2625     return @status;
2626 }
2627
2628 sub _check_issuance_hold_is_possible {
2629     my( $issuanceid, $depth, $request_lib, $patron, $requestor, $pickup_lib, $selection_ou, $oargs ) = @_;
2630
2631     my $e = new_editor();
2632     my %org_filter = create_ranged_org_filter($e, $selection_ou, $depth);
2633
2634     # this monster will grab the id and circ_lib of all of the "holdable" copies for the given record
2635     my $copies = $e->json_query(
2636         {
2637             select => { acp => ['id', 'circ_lib'] },
2638               from => {
2639                 acp => {
2640                     sitem => {
2641                         field  => 'unit',
2642                         fkey   => 'id',
2643                         filter => { issuance => $issuanceid }
2644                     },
2645                     acpl => { field => 'id', filter => { holdable => 't'}, fkey => 'location' },
2646                     ccs  => { field => 'id', filter => { holdable => 't'}, fkey => 'status'   }
2647                 }
2648             },
2649             where => {
2650                 '+acp' => { circulate => 't', deleted => 'f', holdable => 't', %org_filter }
2651             },
2652             distinct => 1
2653         }
2654     );
2655
2656     $logger->info("issuance possible found ".scalar(@$copies)." potential copies");
2657
2658     my $empty_ok;
2659     if (!@$copies) {
2660         $empty_ok = $e->retrieve_config_global_flag('circ.holds.empty_issuance_ok');
2661         $empty_ok = ($empty_ok and $U->is_true($empty_ok->enabled));
2662
2663         return (
2664             0, 0, [
2665                 new OpenILS::Event(
2666                     "HIGH_LEVEL_HOLD_HAS_NO_COPIES",
2667                     "payload" => {"fail_part" => "no_ultimate_items"}
2668                 )
2669             ]
2670         ) unless $empty_ok;
2671
2672         return (1, 0);
2673     }
2674
2675     # -----------------------------------------------------------------------
2676     # sort the copies into buckets based on their circ_lib proximity to
2677     # the patron's home_ou.
2678     # -----------------------------------------------------------------------
2679
2680     my $home_org = $patron->home_ou;
2681     my $req_org = $request_lib->id;
2682
2683     $logger->info("prox cache $home_org " . $prox_cache{$home_org});
2684
2685     $prox_cache{$home_org} =
2686         $e->search_actor_org_unit_proximity({from_org => $home_org})
2687         unless $prox_cache{$home_org};
2688     my $home_prox = $prox_cache{$home_org};
2689
2690     my %buckets;
2691     my %hash = map { ($_->to_org => $_->prox) } @$home_prox;
2692     push( @{$buckets{ $hash{$_->{circ_lib}} } }, $_->{id} ) for @$copies;
2693
2694     my @keys = sort { $a <=> $b } keys %buckets;
2695
2696
2697     if( $home_org ne $req_org ) {
2698       # -----------------------------------------------------------------------
2699       # shove the copies close to the request_lib into the primary buckets
2700       # directly before the farthest away copies.  That way, they are not
2701       # given priority, but they are checked before the farthest copies.
2702       # -----------------------------------------------------------------------
2703         $prox_cache{$req_org} =
2704             $e->search_actor_org_unit_proximity({from_org => $req_org})
2705             unless $prox_cache{$req_org};
2706         my $req_prox = $prox_cache{$req_org};
2707
2708         my %buckets2;
2709         my %hash2 = map { ($_->to_org => $_->prox) } @$req_prox;
2710         push( @{$buckets2{ $hash2{$_->{circ_lib}} } }, $_->{id} ) for @$copies;
2711
2712         my $highest_key = $keys[@keys - 1];  # the farthest prox in the exising buckets
2713         my $new_key = $highest_key - 0.5; # right before the farthest prox
2714         my @keys2   = sort { $a <=> $b } keys %buckets2;
2715         for my $key (@keys2) {
2716             last if $key >= $highest_key;
2717             push( @{$buckets{$new_key}}, $_ ) for @{$buckets2{$key}};
2718         }
2719     }
2720
2721     @keys = sort { $a <=> $b } keys %buckets;
2722
2723     my $title;
2724     my %seen;
2725     my @status;
2726     my $age_protect_only = 0;
2727     OUTER: for my $key (@keys) {
2728       my @cps = @{$buckets{$key}};
2729
2730       $logger->info("looking at " . scalar(@{$buckets{$key}}). " copies in proximity bucket $key");
2731
2732       for my $copyid (@cps) {
2733
2734          next if $seen{$copyid};
2735          $seen{$copyid} = 1; # there could be dupes given the merged buckets
2736          my $copy = $e->retrieve_asset_copy($copyid);
2737          $logger->debug("looking at bucket_key=$key, copy $copyid : circ_lib = " . $copy->circ_lib);
2738
2739          unless($title) { # grab the title if we don't already have it
2740             my $vol = $e->retrieve_asset_call_number(
2741                [ $copy->call_number, { flesh => 1, flesh_fields => { bre => ['fixed_fields'], acn => ['record'] } } ] );
2742             $title = $vol->record;
2743          }
2744
2745          @status = verify_copy_for_hold(
2746             $patron, $requestor, $title, $copy, $pickup_lib, $request_lib, $oargs);
2747
2748          $age_protect_only ||= $status[3];
2749          last OUTER if $status[0];
2750       }
2751     }
2752
2753     if (!$status[0]) {
2754         if (!defined($empty_ok)) {
2755             $empty_ok = $e->retrieve_config_global_flag('circ.holds.empty_issuance_ok');
2756             $empty_ok = ($empty_ok and $U->is_true($empty_ok->enabled));
2757         }
2758
2759         return (1,0) if ($empty_ok);
2760     }
2761     $status[3] = $age_protect_only;
2762     return @status;
2763 }
2764
2765 sub _check_monopart_hold_is_possible {
2766     my( $partid, $depth, $request_lib, $patron, $requestor, $pickup_lib, $selection_ou, $oargs ) = @_;
2767
2768     my $e = new_editor();
2769     my %org_filter = create_ranged_org_filter($e, $selection_ou, $depth);
2770
2771     # this monster will grab the id and circ_lib of all of the "holdable" copies for the given record
2772     my $copies = $e->json_query(
2773         {
2774             select => { acp => ['id', 'circ_lib'] },
2775               from => {
2776                 acp => {
2777                     acpm => {
2778                         field  => 'target_copy',
2779                         fkey   => 'id',
2780                         filter => { part => $partid }
2781                     },
2782                     acpl => { field => 'id', filter => { holdable => 't'}, fkey => 'location' },
2783                     ccs  => { field => 'id', filter => { holdable => 't'}, fkey => 'status'   }
2784                 }
2785             },
2786             where => {
2787                 '+acp' => { circulate => 't', deleted => 'f', holdable => 't', %org_filter }
2788             },
2789             distinct => 1
2790         }
2791     );
2792
2793     $logger->info("monopart possible found ".scalar(@$copies)." potential copies");
2794
2795     my $empty_ok;
2796     if (!@$copies) {
2797         $empty_ok = $e->retrieve_config_global_flag('circ.holds.empty_part_ok');
2798         $empty_ok = ($empty_ok and $U->is_true($empty_ok->enabled));
2799
2800         return (
2801             0, 0, [
2802                 new OpenILS::Event(
2803                     "HIGH_LEVEL_HOLD_HAS_NO_COPIES",
2804                     "payload" => {"fail_part" => "no_ultimate_items"}
2805                 )
2806             ]
2807         ) unless $empty_ok;
2808
2809         return (1, 0);
2810     }
2811
2812     # -----------------------------------------------------------------------
2813     # sort the copies into buckets based on their circ_lib proximity to
2814     # the patron's home_ou.
2815     # -----------------------------------------------------------------------
2816
2817     my $home_org = $patron->home_ou;
2818     my $req_org = $request_lib->id;
2819
2820     $logger->info("prox cache $home_org " . $prox_cache{$home_org});
2821
2822     $prox_cache{$home_org} =
2823         $e->search_actor_org_unit_proximity({from_org => $home_org})
2824         unless $prox_cache{$home_org};
2825     my $home_prox = $prox_cache{$home_org};
2826
2827     my %buckets;
2828     my %hash = map { ($_->to_org => $_->prox) } @$home_prox;
2829     push( @{$buckets{ $hash{$_->{circ_lib}} } }, $_->{id} ) for @$copies;
2830
2831     my @keys = sort { $a <=> $b } keys %buckets;
2832
2833
2834     if( $home_org ne $req_org ) {
2835       # -----------------------------------------------------------------------
2836       # shove the copies close to the request_lib into the primary buckets
2837       # directly before the farthest away copies.  That way, they are not
2838       # given priority, but they are checked before the farthest copies.
2839       # -----------------------------------------------------------------------
2840         $prox_cache{$req_org} =
2841             $e->search_actor_org_unit_proximity({from_org => $req_org})
2842             unless $prox_cache{$req_org};
2843         my $req_prox = $prox_cache{$req_org};
2844
2845         my %buckets2;
2846         my %hash2 = map { ($_->to_org => $_->prox) } @$req_prox;
2847         push( @{$buckets2{ $hash2{$_->{circ_lib}} } }, $_->{id} ) for @$copies;
2848
2849         my $highest_key = $keys[@keys - 1];  # the farthest prox in the exising buckets
2850         my $new_key = $highest_key - 0.5; # right before the farthest prox
2851         my @keys2   = sort { $a <=> $b } keys %buckets2;
2852         for my $key (@keys2) {
2853             last if $key >= $highest_key;
2854             push( @{$buckets{$new_key}}, $_ ) for @{$buckets2{$key}};
2855         }
2856     }
2857
2858     @keys = sort { $a <=> $b } keys %buckets;
2859
2860     my $title;
2861     my %seen;
2862     my @status;
2863     my $age_protect_only = 0;
2864     OUTER: for my $key (@keys) {
2865       my @cps = @{$buckets{$key}};
2866
2867       $logger->info("looking at " . scalar(@{$buckets{$key}}). " copies in proximity bucket $key");
2868
2869       for my $copyid (@cps) {
2870
2871          next if $seen{$copyid};
2872          $seen{$copyid} = 1; # there could be dupes given the merged buckets
2873          my $copy = $e->retrieve_asset_copy($copyid);
2874          $logger->debug("looking at bucket_key=$key, copy $copyid : circ_lib = " . $copy->circ_lib);
2875
2876          unless($title) { # grab the title if we don't already have it
2877             my $vol = $e->retrieve_asset_call_number(
2878                [ $copy->call_number, { flesh => 1, flesh_fields => { bre => ['fixed_fields'], acn => ['record'] } } ] );
2879             $title = $vol->record;
2880          }
2881
2882          @status = verify_copy_for_hold(
2883             $patron, $requestor, $title, $copy, $pickup_lib, $request_lib, $oargs);
2884
2885          $age_protect_only ||= $status[3];
2886          last OUTER if $status[0];
2887       }
2888     }
2889
2890     if (!$status[0]) {
2891         if (!defined($empty_ok)) {
2892             $empty_ok = $e->retrieve_config_global_flag('circ.holds.empty_part_ok');
2893             $empty_ok = ($empty_ok and $U->is_true($empty_ok->enabled));
2894         }
2895
2896         return (1,0) if ($empty_ok);
2897     }
2898     $status[3] = $age_protect_only;
2899     return @status;
2900 }
2901
2902
2903 sub _check_volume_hold_is_possible {
2904     my( $vol, $title, $depth, $request_lib, $patron, $requestor, $pickup_lib, $selection_ou, $oargs ) = @_;
2905     my %org_filter = create_ranged_org_filter(new_editor(), $selection_ou, $depth);
2906     my $copies = new_editor->search_asset_copy({call_number => $vol->id, %org_filter});
2907     $logger->info("checking possibility of volume hold for volume ".$vol->id);
2908
2909     my $filter_copies = [];
2910     for my $copy (@$copies) {
2911         # ignore part-mapped copies for regular volume level holds
2912         push(@$filter_copies, $copy) unless
2913             new_editor->search_asset_copy_part_map({target_copy => $copy->id})->[0];
2914     }
2915     $copies = $filter_copies;
2916
2917     return (
2918         0, 0, [
2919             new OpenILS::Event(
2920                 "HIGH_LEVEL_HOLD_HAS_NO_COPIES",
2921                 "payload" => {"fail_part" => "no_ultimate_items"}
2922             )
2923         ]
2924     ) unless @$copies;
2925
2926     my @status;
2927     my $age_protect_only = 0;
2928     for my $copy ( @$copies ) {
2929         @status = verify_copy_for_hold(
2930             $patron, $requestor, $title, $copy, $pickup_lib, $request_lib, $oargs );
2931         $age_protect_only ||= $status[3];
2932         last if $status[0];
2933     }
2934     $status[3] = $age_protect_only;
2935     return @status;
2936 }
2937
2938
2939
2940 sub verify_copy_for_hold {
2941     my( $patron, $requestor, $title, $copy, $pickup_lib, $request_lib, $oargs ) = @_;
2942     # $oargs should be undef unless we're overriding.
2943     $logger->info("checking possibility of copy in hold request for copy ".$copy->id);
2944     my $permitted = OpenILS::Utils::PermitHold::permit_copy_hold(
2945         {
2946             patron           => $patron,
2947             requestor        => $requestor,
2948             copy             => $copy,
2949             title            => $title,
2950             title_descriptor => $title->fixed_fields,
2951             pickup_lib       => $pickup_lib,
2952             request_lib      => $request_lib,
2953             new_hold         => 1,
2954             show_event_list  => 1
2955         }
2956     );
2957
2958     # Check for override permissions on events.
2959     if ($oargs && $permitted && scalar @$permitted) {
2960         # Remove the events from permitted that we can override.
2961         if ($oargs->{events}) {
2962             foreach my $evt (@{$oargs->{events}}) {
2963                 $permitted = [grep {$_->{textcode} ne $evt} @{$permitted}];
2964             }
2965         }
2966         # Now, we handle the override all case by checking remaining
2967         # events against override permissions.
2968         if (scalar @$permitted && $oargs->{all}) {
2969             # Pre-set events and failed members of oargs to empty
2970             # arrays, if they are not set, yet.
2971             $oargs->{events} = [] unless ($oargs->{events});
2972             $oargs->{failed} = [] unless ($oargs->{failed});
2973             # When we're done with these checks, we swap permitted
2974             # with a reference to @disallowed.
2975             my @disallowed = ();
2976             foreach my $evt (@{$permitted}) {
2977                 # Check if we've already seen the event in this
2978                 # session and it failed.
2979                 if (grep {$_ eq $evt->{textcode}} @{$oargs->{failed}}) {
2980                     push(@disallowed, $evt);
2981                 } else {
2982                     # We have to check if the requestor has the
2983                     # override permission.
2984
2985                     # AppUtils::check_user_perms returns the perm if
2986                     # the user doesn't have it, undef if they do.
2987                     if ($apputils->check_user_perms($requestor->id, $requestor->ws_ou, $evt->{textcode} . '.override')) {
2988                         push(@disallowed, $evt);
2989                         push(@{$oargs->{failed}}, $evt->{textcode});
2990                     } else {
2991                         push(@{$oargs->{events}}, $evt->{textcode});
2992                     }
2993                 }
2994             }
2995             $permitted = \@disallowed;
2996         }
2997     }
2998
2999     my $age_protect_only = 0;
3000     if (@$permitted == 1 && @$permitted[0]->{textcode} eq 'ITEM_AGE_PROTECTED') {
3001         $age_protect_only = 1;
3002     }
3003
3004     return (
3005         (not scalar @$permitted), # true if permitted is an empty arrayref
3006         (   # XXX This test is of very dubious value; someone should figure
3007             # out what if anything is checking this value
3008             ($copy->circ_lib == $pickup_lib) and
3009             ($copy->status == OILS_COPY_STATUS_AVAILABLE)
3010         ),
3011         $permitted,
3012         $age_protect_only
3013     );
3014 }
3015
3016
3017
3018 sub find_nearest_permitted_hold {
3019
3020     my $class  = shift;
3021     my $editor = shift;     # CStoreEditor object
3022     my $copy   = shift;     # copy to target
3023     my $user   = shift;     # staff
3024     my $check_only = shift; # do no updates, just see if the copy could fulfill a hold
3025
3026     my $evt = OpenILS::Event->new('ACTION_HOLD_REQUEST_NOT_FOUND');
3027
3028     my $bc = $copy->barcode;
3029
3030     # find any existing holds that already target this copy
3031     my $old_holds = $editor->search_action_hold_request(
3032         {    current_copy => $copy->id,
3033             cancel_time  => undef,
3034             capture_time => undef
3035         }
3036     );
3037
3038     my $hold_stall_interval = $U->ou_ancestor_setting_value($user->ws_ou, OILS_SETTING_HOLD_SOFT_STALL);
3039
3040     $logger->info("circulator: searching for best hold at org ".$user->ws_ou.
3041         " and copy $bc with a hold stalling interval of ". ($hold_stall_interval || "(none)"));
3042
3043     my $fifo = $U->ou_ancestor_setting_value($user->ws_ou, 'circ.holds_fifo');
3044
3045     # the nearest_hold API call now needs this
3046     $copy->call_number($editor->retrieve_asset_call_number($copy->call_number))
3047         unless ref $copy->call_number;
3048
3049         # search for what should be the best holds for this copy to fulfill
3050         my $best_holds = $U->storagereq(
3051         "open-ils.storage.action.hold_request.nearest_hold.atomic", 
3052                 $user->ws_ou, $copy, 100, $hold_stall_interval, $fifo );
3053
3054     # Add any pre-targeted holds to the list too? Unless they are already there, anyway.
3055     if ($old_holds) {
3056         for my $holdid (@$old_holds) {
3057             next unless $holdid;
3058             push(@$best_holds, $holdid) unless ( grep { ''.$holdid eq ''.$_ } @$best_holds );
3059         }
3060     }
3061
3062     unless(@$best_holds) {
3063         $logger->info("circulator: no suitable holds found for copy $bc");
3064         return (undef, $evt);
3065     }
3066
3067
3068     my $best_hold;
3069
3070     # for each potential hold, we have to run the permit script
3071     # to make sure the hold is actually permitted.
3072     my %reqr_cache;
3073     my %org_cache;
3074     for my $holdid (@$best_holds) {
3075         next unless $holdid;
3076         $logger->info("circulator: checking if hold $holdid is permitted for copy $bc");
3077
3078         my $hold = $editor->retrieve_action_hold_request($holdid) or next;
3079         # Force and recall holds bypass all rules
3080         if ($hold->hold_type eq 'R' || $hold->hold_type eq 'F') {
3081             $best_hold = $hold;
3082             last;
3083         }
3084         my $reqr = $reqr_cache{$hold->requestor} || $editor->retrieve_actor_user($hold->requestor);
3085         my $rlib = $org_cache{$hold->request_lib} || $editor->retrieve_actor_org_unit($hold->request_lib);
3086
3087         $reqr_cache{$hold->requestor} = $reqr;
3088         $org_cache{$hold->request_lib} = $rlib;
3089
3090         # see if this hold is permitted
3091         my $permitted = OpenILS::Utils::PermitHold::permit_copy_hold(
3092             {
3093                 patron_id   => $hold->usr,
3094                 requestor   => $reqr,
3095                 copy        => $copy,
3096                 pickup_lib  => $hold->pickup_lib,
3097                 request_lib => $rlib,
3098                 retarget    => 1
3099             }
3100         );
3101
3102         if( $permitted ) {
3103             $best_hold = $hold;
3104             last;
3105         }
3106     }
3107
3108
3109     unless( $best_hold ) { # no "good" permitted holds were found
3110         # we got nuthin
3111         $logger->info("circulator: no suitable holds found for copy $bc");
3112         return (undef, $evt);
3113     }
3114
3115     $logger->info("circulator: best hold ".$best_hold->id." found for copy $bc");
3116
3117     # indicate a permitted hold was found
3118     return $best_hold if $check_only;
3119
3120     # we've found a permitted hold.  we need to "grab" the copy
3121     # to prevent re-targeted holds (next part) from re-grabbing the copy
3122     $best_hold->current_copy($copy->id);
3123     $editor->update_action_hold_request($best_hold)
3124         or return (undef, $editor->event);
3125
3126
3127     my @retarget;
3128
3129     # re-target any other holds that already target this copy
3130     for my $old_hold (@$old_holds) {
3131         next if $old_hold->id eq $best_hold->id; # don't re-target the hold we want
3132         $logger->info("circulator: clearing current_copy and prev_check_time on hold ".
3133             $old_hold->id." after a better hold [".$best_hold->id."] was found");
3134         $old_hold->clear_current_copy;
3135         $old_hold->clear_prev_check_time;
3136         $editor->update_action_hold_request($old_hold)
3137             or return (undef, $editor->event);
3138         push(@retarget, $old_hold->id);
3139     }
3140
3141     return ($best_hold, undef, (@retarget) ? \@retarget : undef);
3142 }
3143
3144
3145
3146
3147
3148
3149 __PACKAGE__->register_method(
3150     method   => 'all_rec_holds',
3151     api_name => 'open-ils.circ.holds.retrieve_all_from_title',
3152 );
3153
3154 sub all_rec_holds {
3155     my( $self, $conn, $auth, $title_id, $args ) = @_;
3156
3157     my $e = new_editor(authtoken=>$auth);
3158     $e->checkauth or return $e->event;
3159     $e->allowed('VIEW_HOLD') or return $e->event;
3160
3161     $args ||= {};
3162     $args->{fulfillment_time} = undef; #  we don't want to see old fulfilled holds
3163     $args->{cancel_time} = undef;
3164
3165     my $resp = { volume_holds => [], copy_holds => [], recall_holds => [], force_holds => [], metarecord_holds => [], part_holds => [], issuance_holds => [] };
3166
3167     my $mr_map = $e->search_metabib_metarecord_source_map({source => $title_id})->[0];
3168     if($mr_map) {
3169         $resp->{metarecord_holds} = $e->search_action_hold_request(
3170             {   hold_type => OILS_HOLD_TYPE_METARECORD,
3171                 target => $mr_map->metarecord,
3172                 %$args
3173             }, {idlist => 1}
3174         );
3175     }
3176
3177     $resp->{title_holds} = $e->search_action_hold_request(
3178         {
3179             hold_type => OILS_HOLD_TYPE_TITLE,
3180             target => $title_id,
3181             %$args
3182         }, {idlist=>1} );
3183
3184     my $parts = $e->search_biblio_monograph_part(
3185         {
3186             record => $title_id
3187         }, {idlist=>1} );
3188
3189     if (@$parts) {
3190         $resp->{part_holds} = $e->search_action_hold_request(
3191             {
3192                 hold_type => OILS_HOLD_TYPE_MONOPART,
3193                 target => $parts,
3194                 %$args
3195             }, {idlist=>1} );
3196     }
3197
3198     my $subs = $e->search_serial_subscription(
3199         { record_entry => $title_id }, {idlist=>1});
3200
3201     if (@$subs) {
3202         my $issuances = $e->search_serial_issuance(
3203             {subscription => $subs}, {idlist=>1}
3204         );
3205
3206         if ($issuances) {
3207             $resp->{issuance_holds} = $e->search_action_hold_request(
3208                 {
3209                     hold_type => OILS_HOLD_TYPE_ISSUANCE,
3210                     target => $issuances,
3211                     %$args
3212                 }, {idlist=>1}
3213             );
3214         }
3215     }
3216
3217     my $vols = $e->search_asset_call_number(
3218         { record => $title_id, deleted => 'f' }, {idlist=>1});
3219
3220     return $resp unless @$vols;
3221
3222     $resp->{volume_holds} = $e->search_action_hold_request(
3223         {
3224             hold_type => OILS_HOLD_TYPE_VOLUME,
3225             target => $vols,
3226             %$args },
3227         {idlist=>1} );
3228
3229     my $copies = $e->search_asset_copy(
3230         { call_number => $vols, deleted => 'f' }, {idlist=>1});
3231
3232     return $resp unless @$copies;
3233
3234     $resp->{copy_holds} = $e->search_action_hold_request(
3235         {
3236             hold_type => OILS_HOLD_TYPE_COPY,
3237             target => $copies,
3238             %$args },
3239         {idlist=>1} );
3240
3241     $resp->{recall_holds} = $e->search_action_hold_request(
3242         {
3243             hold_type => OILS_HOLD_TYPE_RECALL,
3244             target => $copies,
3245             %$args },
3246         {idlist=>1} );
3247
3248     $resp->{force_holds} = $e->search_action_hold_request(
3249         {
3250             hold_type => OILS_HOLD_TYPE_FORCE,
3251             target => $copies,
3252             %$args },
3253         {idlist=>1} );
3254
3255     return $resp;
3256 }
3257
3258
3259
3260
3261
3262 __PACKAGE__->register_method(
3263     method        => 'uber_hold',
3264     authoritative => 1,
3265     api_name      => 'open-ils.circ.hold.details.retrieve'
3266 );
3267
3268 sub uber_hold {
3269     my($self, $client, $auth, $hold_id, $args) = @_;
3270     my $e = new_editor(authtoken=>$auth);
3271     $e->checkauth or return $e->event;
3272     return uber_hold_impl($e, $hold_id, $args);
3273 }
3274
3275 __PACKAGE__->register_method(
3276     method        => 'batch_uber_hold',
3277     authoritative => 1,
3278     stream        => 1,
3279     api_name      => 'open-ils.circ.hold.details.batch.retrieve'
3280 );
3281
3282 sub batch_uber_hold {
3283     my($self, $client, $auth, $hold_ids, $args) = @_;
3284     my $e = new_editor(authtoken=>$auth);
3285     $e->checkauth or return $e->event;
3286     $client->respond(uber_hold_impl($e, $_, $args)) for @$hold_ids;
3287     return undef;
3288 }
3289
3290 sub uber_hold_impl {
3291     my($e, $hold_id, $args) = @_;
3292     $args ||= {};
3293
3294     my $hold = $e->retrieve_action_hold_request(
3295         [
3296             $hold_id,
3297             {
3298                 flesh => 1,
3299                 flesh_fields => { ahr => [ 'current_copy', 'usr', 'notes' ] }
3300             }
3301         ]
3302     ) or return $e->event;
3303
3304     if($hold->usr->id ne $e->requestor->id) {
3305         # caller is asking for someone else's hold
3306         $e->allowed('VIEW_HOLD') or return $e->event;
3307         $hold->notes( # filter out any non-staff ("private") notes (unless marked as public)
3308             [ grep { $U->is_true($_->staff) or $U->is_true($_->pub) } @{$hold->notes} ] );
3309
3310     } else {
3311         # caller is asking for own hold, but may not have permission to view staff notes
3312         unless($e->allowed('VIEW_HOLD')) {
3313             $hold->notes( # filter out any staff notes (unless marked as public)
3314                 [ grep { !$U->is_true($_->staff) or $U->is_true($_->pub) } @{$hold->notes} ] );
3315         }
3316     }
3317
3318     my $user = $hold->usr;
3319     $hold->usr($user->id);
3320
3321
3322     my( $mvr, $volume, $copy, $issuance, $part, $bre ) = find_hold_mvr($e, $hold, $args->{suppress_mvr});
3323
3324     flesh_hold_notices([$hold], $e) unless $args->{suppress_notices};
3325     flesh_hold_transits([$hold]) unless $args->{suppress_transits};
3326
3327     my $details = retrieve_hold_queue_status_impl($e, $hold);
3328
3329     my $resp = {
3330         hold    => $hold,
3331         bre_id  => $bre->id,
3332         ($copy     ? (copy           => $copy)     : ()),
3333         ($volume   ? (volume         => $volume)   : ()),
3334         ($issuance ? (issuance       => $issuance) : ()),
3335         ($part     ? (part           => $part)     : ()),
3336         ($args->{include_bre}  ?  (bre => $bre)    : ()),
3337         ($args->{suppress_mvr} ?  () : (mvr => $mvr)),
3338         %$details
3339     };
3340
3341     unless($args->{suppress_patron_details}) {
3342         my $card = $e->retrieve_actor_card($user->card) or return $e->event;
3343         $resp->{patron_first}   = $user->first_given_name,
3344         $resp->{patron_last}    = $user->family_name,
3345         $resp->{patron_barcode} = $card->barcode,
3346         $resp->{patron_alias}   = $user->alias,
3347     };
3348
3349     return $resp;
3350 }
3351
3352
3353
3354 # -----------------------------------------------------
3355 # Returns the MVR object that represents what the
3356 # hold is all about
3357 # -----------------------------------------------------
3358 sub find_hold_mvr {
3359     my( $e, $hold, $no_mvr ) = @_;
3360
3361     my $tid;
3362     my $copy;
3363     my $volume;
3364     my $issuance;
3365     my $part;
3366
3367     if( $hold->hold_type eq OILS_HOLD_TYPE_METARECORD ) {
3368         my $mr = $e->retrieve_metabib_metarecord($hold->target)
3369             or return $e->event;
3370         $tid = $mr->master_record;
3371
3372     } elsif( $hold->hold_type eq OILS_HOLD_TYPE_TITLE ) {
3373         $tid = $hold->target;
3374
3375     } elsif( $hold->hold_type eq OILS_HOLD_TYPE_VOLUME ) {
3376         $volume = $e->retrieve_asset_call_number($hold->target)
3377             or return $e->event;
3378         $tid = $volume->record;
3379
3380     } elsif( $hold->hold_type eq OILS_HOLD_TYPE_ISSUANCE ) {
3381         $issuance = $e->retrieve_serial_issuance([
3382             $hold->target,
3383             {flesh => 1, flesh_fields => {siss => [ qw/subscription/ ]}}
3384         ]) or return $e->event;
3385
3386         $tid = $issuance->subscription->record_entry;
3387
3388     } elsif( $hold->hold_type eq OILS_HOLD_TYPE_MONOPART ) {
3389         $part = $e->retrieve_biblio_monograph_part([
3390             $hold->target
3391         ]) or return $e->event;
3392
3393         $tid = $part->record;
3394
3395     } 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 ) {
3396         $copy = $e->retrieve_asset_copy([
3397             $hold->target,
3398             {flesh => 1, flesh_fields => {acp => ['call_number']}}
3399         ]) or return $e->event;
3400
3401         $volume = $copy->call_number;
3402         $tid = $volume->record;
3403     }
3404
3405     if(!$copy and ref $hold->current_copy ) {
3406         $copy = $hold->current_copy;
3407         $hold->current_copy($copy->id);
3408     }
3409
3410     if(!$volume and $copy) {
3411         $volume = $e->retrieve_asset_call_number($copy->call_number);
3412     }
3413
3414     # TODO return metarcord mvr for M holds
3415     my $title = $e->retrieve_biblio_record_entry($tid);
3416     return ( ($no_mvr) ? undef : $U->record_to_mvr($title), $volume, $copy, $issuance, $part, $title );
3417 }
3418
3419 __PACKAGE__->register_method(
3420     method    => 'clear_shelf_cache',
3421     api_name  => 'open-ils.circ.hold.clear_shelf.get_cache',
3422     stream    => 1,
3423     signature => {
3424         desc => q/
3425             Returns the holds processed with the given cache key
3426         /
3427     }
3428 );
3429
3430 sub clear_shelf_cache {
3431     my($self, $client, $auth, $cache_key, $chunk_size) = @_;
3432     my $e = new_editor(authtoken => $auth, xact => 1);
3433     return $e->die_event unless $e->checkauth and $e->allowed('VIEW_HOLD');
3434
3435     $chunk_size ||= 25;
3436     my $hold_data = OpenSRF::Utils::Cache->new('global')->get_cache($cache_key);
3437
3438     if (!$hold_data) {
3439         $logger->info("no hold data found in cache"); # XXX TODO return event
3440         $e->rollback;
3441         return undef;
3442     }
3443
3444     my $maximum = 0;
3445     foreach (keys %$hold_data) {
3446         $maximum += scalar(@{ $hold_data->{$_} });
3447     }
3448     $client->respond({"maximum" => $maximum, "progress" => 0});
3449
3450     for my $action (sort keys %$hold_data) {
3451         while (@{$hold_data->{$action}}) {
3452             my @hid_chunk = splice @{$hold_data->{$action}}, 0, $chunk_size;
3453
3454             my $result_chunk = $e->json_query({
3455                 "select" => {
3456                     "acp" => ["barcode"],
3457                     "au" => [qw/
3458                         first_given_name second_given_name family_name alias
3459                     /],
3460                     "acn" => ["label"],
3461                     "acnp" => [{column => "label", alias => "prefix"}],
3462                     "acns" => [{column => "label", alias => "suffix"}],
3463                     "bre" => ["marc"],
3464                     "acpl" => ["name"],
3465                     "ahr" => ["id"]
3466                 },
3467                 "from" => {
3468                     "ahr" => {
3469                         "acp" => {
3470                             "field" => "id", "fkey" => "current_copy",
3471                             "join" => {
3472                                 "acn" => {
3473                                     "field" => "id", "fkey" => "call_number",
3474                                     "join" => {
3475                                         "bre" => {
3476                                             "field" => "id", "fkey" => "record"
3477                                         },
3478                                         "acnp" => {
3479                                             "field" => "id", "fkey" => "prefix"
3480                                         },
3481                                         "acns" => {
3482                                             "field" => "id", "fkey" => "suffix"
3483                                         }
3484                                     }
3485                                 },
3486                                 "acpl" => {"field" => "id", "fkey" => "location"}
3487                             }
3488                         },
3489                         "au" => {"field" => "id", "fkey" => "usr"}
3490                     }
3491                 },
3492                 "where" => {"+ahr" => {"id" => \@hid_chunk}}
3493             }, {"substream" => 1}) or return $e->die_event;
3494
3495             $client->respond([
3496                 map {
3497                     +{"action" => $action, "hold_details" => $_}
3498                 } @$result_chunk
3499             ]);
3500         }
3501     }
3502
3503     $e->rollback;
3504     return undef;
3505 }
3506
3507
3508 __PACKAGE__->register_method(
3509     method    => 'clear_shelf_process',
3510     stream    => 1,
3511     api_name  => 'open-ils.circ.hold.clear_shelf.process',
3512     signature => {
3513         desc => q/
3514             1. Find all holds that have expired on the holds shelf
3515             2. Cancel the holds
3516             3. If a clear-shelf status is configured, put targeted copies into this status
3517             4. Divide copies into 3 groups: items to transit, items to reshelve, and items
3518                 that are needed for holds.  No subsequent action is taken on the holds
3519                 or items after grouping.
3520         /
3521     }
3522 );
3523
3524 sub clear_shelf_process {
3525     my($self, $client, $auth, $org_id, $match_copy) = @_;
3526
3527     my $e = new_editor(authtoken=>$auth);
3528     $e->checkauth or return $e->die_event;
3529     my $cache = OpenSRF::Utils::Cache->new('global');
3530
3531     $org_id ||= $e->requestor->ws_ou;
3532     $e->allowed('UPDATE_HOLD', $org_id) or return $e->die_event;
3533
3534     my $copy_status = $U->ou_ancestor_setting_value($org_id, 'circ.holds.clear_shelf.copy_status');
3535
3536     my @hold_ids = $self->method_lookup(
3537         "open-ils.circ.captured_holds.id_list.expired_on_shelf.retrieve"
3538     )->run($auth, $org_id, $match_copy);
3539
3540     $e->xact_begin;
3541
3542     my @holds;
3543     my @canceled_holds; # newly canceled holds
3544     my $chunk_size = 25; # chunked status updates
3545     my $counter = 0;
3546     for my $hold_id (@hold_ids) {
3547
3548         $logger->info("Clear shelf processing hold $hold_id");
3549
3550         my $hold = $e->retrieve_action_hold_request([
3551             $hold_id, {
3552                 flesh => 1,
3553                 flesh_fields => {ahr => ['current_copy']}
3554             }
3555         ]);
3556
3557         if (!$hold->cancel_time) { # may be canceled but still on the holds shelf
3558             $hold->cancel_time('now');
3559             $hold->cancel_cause(2); # Hold Shelf expiration
3560           &nbs