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