]> git.evergreen-ils.org Git - Evergreen.git/blob - Open-ILS/src/perlmods/lib/OpenILS/Application/Circ/Holds.pm
LP#1350042 streaming holds pull list API
[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(time_zone => 'local'); # without time_zone we get UTC ... yuck!
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 __PACKAGE__->register_method(
1559     method    => "hold_pull_list",
1560     stream => 1,
1561     # TODO: tag with api_level 2 once fully supported
1562     api_name  => "open-ils.circ.hold_pull_list.fleshed.stream",
1563     signature => {
1564         desc   => q/Returns a stream of fleshed holds  that need to be 
1565                     "pulled" by a given location.  The location is 
1566                     determined by the login session.  
1567                     This API calls always run in authoritative mode./,
1568         params => [
1569             { desc => 'Limit (optional)',  type => 'number'},
1570             { desc => 'Offset (optional)', type => 'number'},
1571         ],
1572         return => {
1573             desc => 'Stream of holds holds, or event on failure',
1574         }
1575     }
1576 );
1577
1578 sub hold_pull_list {
1579     my( $self, $conn, $authtoken, $limit, $offset ) = @_;
1580     my( $reqr, $evt ) = $U->checkses($authtoken);
1581     return $evt if $evt;
1582
1583     my $org = $reqr->ws_ou || $reqr->home_ou;
1584     # the perm locaiton shouldn't really matter here since holds
1585     # will exist all over and VIEW_HOLDS should be universal
1586     $evt = $U->check_perms($reqr->id, $org, 'VIEW_HOLD');
1587     return $evt if $evt;
1588
1589     if($self->api_name =~ /count/) {
1590
1591         my $count = $U->storagereq(
1592             'open-ils.storage.direct.action.hold_request.pull_list.current_copy_circ_lib.status_filtered.count',
1593             $org, $limit, $offset );
1594
1595         $logger->info("Grabbing pull list for org unit $org with $count items");
1596         return $count;
1597
1598     } elsif( $self->api_name =~ /id_list/ ) {
1599         $U->storagereq(
1600             'open-ils.storage.direct.action.hold_request.pull_list.id_list.current_copy_circ_lib.status_filtered.atomic',
1601             $org, $limit, $offset );
1602
1603     } elsif ($self->api_name =~ /fleshed/) {
1604
1605         my $ids = $U->storagereq(
1606             'open-ils.storage.direct.action.hold_request.pull_list.id_list.current_copy_circ_lib.status_filtered.atomic',
1607             $org, $limit, $offset );
1608
1609         my $e = new_editor(xact => 1, requestor => $reqr);
1610         $conn->respond(uber_hold_impl($e, $_, {flesh_acpl => 1})) for @$ids;
1611         $e->rollback;
1612         $conn->respond_complete;
1613         return;
1614
1615     } else {
1616         $U->storagereq(
1617             'open-ils.storage.direct.action.hold_request.pull_list.search.current_copy_circ_lib.status_filtered.atomic',
1618             $org, $limit, $offset );
1619     }
1620 }
1621
1622 __PACKAGE__->register_method(
1623     method    => "print_hold_pull_list",
1624     api_name  => "open-ils.circ.hold_pull_list.print",
1625     signature => {
1626         desc   => 'Returns an HTML-formatted holds pull list',
1627         params => [
1628             { desc => 'Authtoken', type => 'string'},
1629             { desc => 'Org unit ID.  Optional, defaults to workstation org unit', type => 'number'},
1630         ],
1631         return => {
1632             desc => 'HTML string',
1633             type => 'string'
1634         }
1635     }
1636 );
1637
1638 sub print_hold_pull_list {
1639     my($self, $client, $auth, $org_id) = @_;
1640
1641     my $e = new_editor(authtoken=>$auth);
1642     return $e->event unless $e->checkauth;
1643
1644     $org_id = (defined $org_id) ? $org_id : $e->requestor->ws_ou;
1645     return $e->event unless $e->allowed('VIEW_HOLD', $org_id);
1646
1647     my $hold_ids = $U->storagereq(
1648         'open-ils.storage.direct.action.hold_request.pull_list.id_list.current_copy_circ_lib.status_filtered.atomic',
1649         $org_id, 10000);
1650
1651     return undef unless @$hold_ids;
1652
1653     $client->status(new OpenSRF::DomainObject::oilsContinueStatus);
1654
1655     # Holds will /NOT/ be in order after this ...
1656     my $holds = $e->search_action_hold_request({id => $hold_ids}, {substream => 1});
1657     $client->status(new OpenSRF::DomainObject::oilsContinueStatus);
1658
1659     # ... so we must resort.
1660     my $hold_map = +{map { $_->id => $_ } @$holds};
1661     my $sorted_holds = [];
1662     push @$sorted_holds, $hold_map->{$_} foreach @$hold_ids;
1663
1664     return $U->fire_object_event(
1665         undef, "ahr.format.pull_list", $sorted_holds,
1666         $org_id, undef, undef, $client
1667     );
1668
1669 }
1670
1671 __PACKAGE__->register_method(
1672     method    => "print_hold_pull_list_stream",
1673     stream   => 1,
1674     api_name  => "open-ils.circ.hold_pull_list.print.stream",
1675     signature => {
1676         desc   => 'Returns a stream of fleshed holds',
1677         params => [
1678             { desc => 'Authtoken', type => 'string'},
1679             { 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)',
1680               type => 'object'
1681             },
1682         ],
1683         return => {
1684             desc => 'A stream of fleshed holds',
1685             type => 'object'
1686         }
1687     }
1688 );
1689
1690 sub print_hold_pull_list_stream {
1691     my($self, $client, $auth, $params) = @_;
1692
1693     my $e = new_editor(authtoken=>$auth);
1694     return $e->die_event unless $e->checkauth;
1695
1696     delete($$params{org_id}) unless (int($$params{org_id}));
1697     delete($$params{limit}) unless (int($$params{limit}));
1698     delete($$params{offset}) unless (int($$params{offset}));
1699     delete($$params{chunk_size}) unless (int($$params{chunk_size}));
1700     delete($$params{chunk_size}) if  ($$params{chunk_size} && $$params{chunk_size} > 50); # keep the size reasonable
1701     $$params{chunk_size} ||= 10;
1702
1703     $$params{org_id} = (defined $$params{org_id}) ? $$params{org_id}: $e->requestor->ws_ou;
1704     return $e->die_event unless $e->allowed('VIEW_HOLD', $$params{org_id });
1705
1706     my $sort = [];
1707     if ($$params{sort} && @{ $$params{sort} }) {
1708         for my $s (@{ $$params{sort} }) {
1709             if ($s eq 'acplo.position') {
1710                 push @$sort, {
1711                     "class" => "acplo", "field" => "position",
1712                     "transform" => "coalesce", "params" => [999]
1713                 };
1714             } elsif ($s eq 'prefix') {
1715                 push @$sort, {"class" => "acnp", "field" => "label_sortkey"};
1716             } elsif ($s eq 'call_number') {
1717                 push @$sort, {"class" => "acn", "field" => "label_sortkey"};
1718             } elsif ($s eq 'suffix') {
1719                 push @$sort, {"class" => "acns", "field" => "label_sortkey"};
1720             } elsif ($s eq 'request_time') {
1721                 push @$sort, {"class" => "ahr", "field" => "request_time"};
1722             }
1723         }
1724     } else {
1725         push @$sort, {"class" => "ahr", "field" => "request_time"};
1726     }
1727
1728     my $holds_ids = $e->json_query(
1729         {
1730             "select" => {"ahr" => ["id"]},
1731             "from" => {
1732                 "ahr" => {
1733                     "acp" => {
1734                         "field" => "id",
1735                         "fkey" => "current_copy",
1736                         "filter" => {
1737                             "circ_lib" => $$params{org_id}, "status" => [0,7]
1738                         },
1739                         "join" => {
1740                             "acn" => {
1741                                 "field" => "id",
1742                                 "fkey" => "call_number",
1743                                 "join" => {
1744                                     "acnp" => {
1745                                         "field" => "id",
1746                                         "fkey" => "prefix"
1747                                     },
1748                                     "acns" => {
1749                                         "field" => "id",
1750                                         "fkey" => "suffix"
1751                                     }
1752                                 }
1753                             },
1754                             "acplo" => {
1755                                 "field" => "org",
1756                                 "fkey" => "circ_lib",
1757                                 "type" => "left",
1758                                 "filter" => {
1759                                     "location" => {"=" => {"+acp" => "location"}}
1760                                 }
1761                             }
1762                         }
1763                     }
1764                 }
1765             },
1766             "where" => {
1767                 "+ahr" => {
1768                     "capture_time" => undef,
1769                     "cancel_time" => undef,
1770                     "-or" => [
1771                         {"expire_time" => undef },
1772                         {"expire_time" => {">" => "now"}}
1773                     ]
1774                 }
1775             },
1776             (@$sort ? (order_by => $sort) : ()),
1777             ($$params{limit} ? (limit => $$params{limit}) : ()),
1778             ($$params{offset} ? (offset => $$params{offset}) : ())
1779         }, {"substream" => 1}
1780     ) or return $e->die_event;
1781
1782     $logger->info("about to stream back " . scalar(@$holds_ids) . " holds");
1783
1784     my @chunk;
1785     for my $hid (@$holds_ids) {
1786         push @chunk, $e->retrieve_action_hold_request([
1787             $hid->{"id"}, {
1788                 "flesh" => 3,
1789                 "flesh_fields" => {
1790                     "ahr" => ["usr", "current_copy"],
1791                     "au"  => ["card"],
1792                     "acp" => ["location", "call_number", "parts"],
1793                     "acn" => ["record","prefix","suffix"]
1794                 }
1795             }
1796         ]);
1797
1798         if (@chunk >= $$params{chunk_size}) {
1799             $client->respond( \@chunk );
1800             @chunk = ();
1801         }
1802     }
1803     $client->respond_complete( \@chunk ) if (@chunk);
1804     $e->disconnect;
1805     return undef;
1806 }
1807
1808
1809
1810 __PACKAGE__->register_method(
1811     method        => 'fetch_hold_notify',
1812     api_name      => 'open-ils.circ.hold_notification.retrieve_by_hold',
1813     authoritative => 1,
1814     signature     => q/
1815 Returns a list of hold notification objects based on hold id.
1816 @param authtoken The loggin session key
1817 @param holdid The id of the hold whose notifications we want to retrieve
1818 @return An array of hold notification objects, event on error.
1819 /
1820 );
1821
1822 sub fetch_hold_notify {
1823     my( $self, $conn, $authtoken, $holdid ) = @_;
1824     my( $requestor, $evt ) = $U->checkses($authtoken);
1825     return $evt if $evt;
1826     my ($hold, $patron);
1827     ($hold, $evt) = $U->fetch_hold($holdid);
1828     return $evt if $evt;
1829     ($patron, $evt) = $U->fetch_user($hold->usr);
1830     return $evt if $evt;
1831
1832     $evt = $U->check_perms($requestor->id, $patron->home_ou, 'VIEW_HOLD_NOTIFICATION');
1833     return $evt if $evt;
1834
1835     $logger->info("User ".$requestor->id." fetching hold notifications for hold $holdid");
1836     return $U->cstorereq(
1837         'open-ils.cstore.direct.action.hold_notification.search.atomic', {hold => $holdid} );
1838 }
1839
1840
1841 __PACKAGE__->register_method(
1842     method    => 'create_hold_notify',
1843     api_name  => 'open-ils.circ.hold_notification.create',
1844     signature => q/
1845 Creates a new hold notification object
1846 @param authtoken The login session key
1847 @param notification The hold notification object to create
1848 @return ID of the new object on success, Event on error
1849 /
1850 );
1851
1852 sub create_hold_notify {
1853    my( $self, $conn, $auth, $note ) = @_;
1854    my $e = new_editor(authtoken=>$auth, xact=>1);
1855    return $e->die_event unless $e->checkauth;
1856
1857    my $hold = $e->retrieve_action_hold_request($note->hold)
1858       or return $e->die_event;
1859    my $patron = $e->retrieve_actor_user($hold->usr)
1860       or return $e->die_event;
1861
1862    return $e->die_event unless
1863       $e->allowed('CREATE_HOLD_NOTIFICATION', $patron->home_ou);
1864
1865    $note->notify_staff($e->requestor->id);
1866    $e->create_action_hold_notification($note) or return $e->die_event;
1867    $e->commit;
1868    return $note->id;
1869 }
1870
1871 __PACKAGE__->register_method(
1872     method    => 'create_hold_note',
1873     api_name  => 'open-ils.circ.hold_note.create',
1874     signature => q/
1875         Creates a new hold request note object
1876         @param authtoken The login session key
1877         @param note The hold note object to create
1878         @return ID of the new object on success, Event on error
1879         /
1880 );
1881
1882 sub create_hold_note {
1883    my( $self, $conn, $auth, $note ) = @_;
1884    my $e = new_editor(authtoken=>$auth, xact=>1);
1885    return $e->die_event unless $e->checkauth;
1886
1887    my $hold = $e->retrieve_action_hold_request($note->hold)
1888       or return $e->die_event;
1889    my $patron = $e->retrieve_actor_user($hold->usr)
1890       or return $e->die_event;
1891
1892    return $e->die_event unless
1893       $e->allowed('UPDATE_HOLD', $patron->home_ou); # FIXME: Using permcrud perm listed in fm_IDL.xml for ahrn.  Probably want something more specific
1894
1895    $e->create_action_hold_request_note($note) or return $e->die_event;
1896    $e->commit;
1897    return $note->id;
1898 }
1899
1900 __PACKAGE__->register_method(
1901     method    => 'reset_hold',
1902     api_name  => 'open-ils.circ.hold.reset',
1903     signature => q/
1904         Un-captures and un-targets a hold, essentially returning
1905         it to the state it was in directly after it was placed,
1906         then attempts to re-target the hold
1907         @param authtoken The login session key
1908         @param holdid The id of the hold
1909     /
1910 );
1911
1912
1913 sub reset_hold {
1914     my( $self, $conn, $auth, $holdid ) = @_;
1915     my $reqr;
1916     my ($hold, $evt) = $U->fetch_hold($holdid);
1917     return $evt if $evt;
1918     ($reqr, $evt) = $U->checksesperm($auth, 'UPDATE_HOLD');
1919     return $evt if $evt;
1920     $evt = _reset_hold($self, $reqr, $hold);
1921     return $evt if $evt;
1922     return 1;
1923 }
1924
1925
1926 __PACKAGE__->register_method(
1927     method   => 'reset_hold_batch',
1928     api_name => 'open-ils.circ.hold.reset.batch'
1929 );
1930
1931 sub reset_hold_batch {
1932     my($self, $conn, $auth, $hold_ids) = @_;
1933
1934     my $e = new_editor(authtoken => $auth);
1935     return $e->event unless $e->checkauth;
1936
1937     for my $hold_id ($hold_ids) {
1938
1939         my $hold = $e->retrieve_action_hold_request(
1940             [$hold_id, {flesh => 1, flesh_fields => {ahr => ['usr']}}])
1941             or return $e->event;
1942
1943         next unless $e->allowed('UPDATE_HOLD', $hold->usr->home_ou);
1944         _reset_hold($self, $e->requestor, $hold);
1945     }
1946
1947     return 1;
1948 }
1949
1950
1951 sub _reset_hold {
1952     my ($self, $reqr, $hold) = @_;
1953
1954     my $e = new_editor(xact =>1, requestor => $reqr);
1955
1956     $logger->info("reseting hold ".$hold->id);
1957
1958     my $hid = $hold->id;
1959
1960     if( $hold->capture_time and $hold->current_copy ) {
1961
1962         my $copy = $e->retrieve_asset_copy($hold->current_copy)
1963             or return $e->die_event;
1964
1965         if( $copy->status == OILS_COPY_STATUS_ON_HOLDS_SHELF ) {
1966             $logger->info("setting copy to status 'reshelving' on hold retarget");
1967             $copy->status(OILS_COPY_STATUS_RESHELVING);
1968             $copy->editor($e->requestor->id);
1969             $copy->edit_date('now');
1970             $e->update_asset_copy($copy) or return $e->die_event;
1971
1972         } elsif( $copy->status == OILS_COPY_STATUS_IN_TRANSIT ) {
1973
1974             # We don't want the copy to remain "in transit"
1975             $copy->status(OILS_COPY_STATUS_RESHELVING);
1976             $logger->warn("! reseting hold [$hid] that is in transit");
1977             my $transid = $e->search_action_hold_transit_copy({hold=>$hold->id},{idlist=>1})->[0];
1978
1979             if( $transid ) {
1980                 my $trans = $e->retrieve_action_transit_copy($transid);
1981                 if( $trans ) {
1982                     $logger->info("Aborting transit [$transid] on hold [$hid] reset...");
1983                     my $evt = OpenILS::Application::Circ::Transit::__abort_transit($e, $trans, $copy, 1, 1);
1984                     $logger->info("Transit abort completed with result $evt");
1985                     unless ("$evt" eq 1) {
1986                         $e->rollback;
1987                         return $evt;
1988                     }
1989                 }
1990             }
1991         }
1992     }
1993
1994     $hold->clear_capture_time;
1995     $hold->clear_current_copy;
1996     $hold->clear_shelf_time;
1997     $hold->clear_shelf_expire_time;
1998     $hold->clear_current_shelf_lib;
1999
2000     $e->update_action_hold_request($hold) or return $e->die_event;
2001     $e->commit;
2002
2003     $U->storagereq(
2004         'open-ils.storage.action.hold_request.copy_targeter', undef, $hold->id );
2005
2006     return undef;
2007 }
2008
2009
2010 __PACKAGE__->register_method(
2011     method    => 'fetch_open_title_holds',
2012     api_name  => 'open-ils.circ.open_holds.retrieve',
2013     signature => q/
2014         Returns a list ids of un-fulfilled holds for a given title id
2015         @param authtoken The login session key
2016         @param id the id of the item whose holds we want to retrieve
2017         @param type The hold type - M, T, I, V, C, F, R
2018     /
2019 );
2020
2021 sub fetch_open_title_holds {
2022     my( $self, $conn, $auth, $id, $type, $org ) = @_;
2023     my $e = new_editor( authtoken => $auth );
2024     return $e->event unless $e->checkauth;
2025
2026     $type ||= "T";
2027     $org  ||= $e->requestor->ws_ou;
2028
2029 #    return $e->search_action_hold_request(
2030 #        { target => $id, hold_type => $type, fulfillment_time => undef }, {idlist=>1});
2031
2032     # XXX make me return IDs in the future ^--
2033     my $holds = $e->search_action_hold_request(
2034         {
2035             target           => $id,
2036             cancel_time      => undef,
2037             hold_type        => $type,
2038             fulfillment_time => undef
2039         }
2040     );
2041
2042     flesh_hold_transits($holds);
2043     return $holds;
2044 }
2045
2046
2047 sub flesh_hold_transits {
2048     my $holds = shift;
2049     for my $hold ( @$holds ) {
2050         $hold->transit(
2051             $apputils->simplereq(
2052                 'open-ils.cstore',
2053                 "open-ils.cstore.direct.action.hold_transit_copy.search.atomic",
2054                 { hold => $hold->id },
2055                 { order_by => { ahtc => 'id desc' }, limit => 1 }
2056             )->[0]
2057         );
2058     }
2059 }
2060
2061 sub flesh_hold_notices {
2062     my( $holds, $e ) = @_;
2063     $e ||= new_editor();
2064
2065     for my $hold (@$holds) {
2066         my $notices = $e->search_action_hold_notification(
2067             [
2068                 { hold => $hold->id },
2069                 { order_by => { anh => 'notify_time desc' } },
2070             ],
2071             {idlist=>1}
2072         );
2073
2074         $hold->notify_count(scalar(@$notices));
2075         if( @$notices ) {
2076             my $n = $e->retrieve_action_hold_notification($$notices[0])
2077                 or return $e->event;
2078             $hold->notify_time($n->notify_time);
2079         }
2080     }
2081 }
2082
2083
2084 __PACKAGE__->register_method(
2085     method    => 'fetch_captured_holds',
2086     api_name  => 'open-ils.circ.captured_holds.on_shelf.retrieve',
2087     stream    => 1,
2088     authoritative => 1,
2089     signature => q/
2090         Returns a list of un-fulfilled holds (on the Holds Shelf) for a given title id
2091         @param authtoken The login session key
2092         @param org The org id of the location in question
2093         @param match_copy A specific copy to limit to
2094     /
2095 );
2096
2097 __PACKAGE__->register_method(
2098     method    => 'fetch_captured_holds',
2099     api_name  => 'open-ils.circ.captured_holds.id_list.on_shelf.retrieve',
2100     stream    => 1,
2101     authoritative => 1,
2102     signature => q/
2103         Returns list ids of un-fulfilled holds (on the Holds Shelf) for a given title id
2104         @param authtoken The login session key
2105         @param org The org id of the location in question
2106         @param match_copy A specific copy to limit to
2107     /
2108 );
2109
2110 __PACKAGE__->register_method(
2111     method    => 'fetch_captured_holds',
2112     api_name  => 'open-ils.circ.captured_holds.id_list.expired_on_shelf.retrieve',
2113     stream    => 1,
2114     authoritative => 1,
2115     signature => q/
2116         Returns list ids of shelf-expired un-fulfilled holds for a given title id
2117         @param authtoken The login session key
2118         @param org The org id of the location in question
2119         @param match_copy A specific copy to limit to
2120     /
2121 );
2122
2123 __PACKAGE__->register_method(
2124     method    => 'fetch_captured_holds',
2125     api_name  => 
2126       'open-ils.circ.captured_holds.expired_on_shelf_or_wrong_shelf.retrieve',
2127     stream    => 1,
2128     authoritative => 1,
2129     signature => q/
2130         Returns list of shelf-expired un-fulfilled holds OR wrong shelf holds
2131         for a given shelf lib
2132     /
2133 );
2134
2135 __PACKAGE__->register_method(
2136     method    => 'fetch_captured_holds',
2137     api_name  => 
2138       'open-ils.circ.captured_holds.id_list.expired_on_shelf_or_wrong_shelf.retrieve',
2139     stream    => 1,
2140     authoritative => 1,
2141     signature => q/
2142         Returns list of shelf-expired un-fulfilled holds OR wrong shelf holds
2143         for a given shelf lib
2144     /
2145 );
2146
2147
2148 sub fetch_captured_holds {
2149     my( $self, $conn, $auth, $org, $match_copy ) = @_;
2150
2151     my $e = new_editor(authtoken => $auth);
2152     return $e->die_event unless $e->checkauth;
2153     return $e->die_event unless $e->allowed('VIEW_HOLD'); # XXX rely on editor perm
2154
2155     $org ||= $e->requestor->ws_ou;
2156
2157     my $current_copy = { '!=' => undef };
2158     $current_copy = { '=' => $match_copy } if $match_copy;
2159
2160     my $query = {
2161         select => { alhr => ['id'] },
2162         from   => {
2163             alhr => {
2164                 acp => {
2165                     field => 'id',
2166                     fkey  => 'current_copy'
2167                 },
2168             }
2169         },
2170         where => {
2171             '+acp' => { status => OILS_COPY_STATUS_ON_HOLDS_SHELF, deleted => 'f' },
2172             '+alhr' => {
2173                 capture_time      => { "!=" => undef },
2174                 current_copy      => $current_copy,
2175                 fulfillment_time  => undef,
2176                 current_shelf_lib => $org
2177             }
2178         }
2179     };
2180     if($self->api_name =~ /expired/) {
2181         $query->{'where'}->{'+alhr'}->{'-or'} = {
2182                 shelf_expire_time => { '<' => 'today'},
2183                 cancel_time => { '!=' => undef },
2184         };
2185     }
2186     my $hold_ids = $e->json_query( $query );
2187
2188     if ($self->api_name =~ /wrong_shelf/) {
2189         # fetch holds whose current_shelf_lib is $org, but whose pickup 
2190         # lib is some other org unit.  Ignore already-retrieved holds.
2191         my $wrong_shelf =
2192             pickup_lib_changed_on_shelf_holds(
2193                 $e, $org, [map {$_->{id}} @$hold_ids]);
2194         # match the layout of other items in $hold_ids
2195         push (@$hold_ids, {id => $_}) for @$wrong_shelf;
2196     }
2197
2198
2199     for my $hold_id (@$hold_ids) {
2200         if($self->api_name =~ /id_list/) {
2201             $conn->respond($hold_id->{id});
2202             next;
2203         } else {
2204             $conn->respond(
2205                 $e->retrieve_action_hold_request([
2206                     $hold_id->{id},
2207                     {
2208                         flesh => 1,
2209                         flesh_fields => {ahr => ['notifications', 'transit', 'notes']},
2210                         order_by => {anh => 'notify_time desc'}
2211                     }
2212                 ])
2213             );
2214         }
2215     }
2216
2217     return undef;
2218 }
2219
2220 __PACKAGE__->register_method(
2221     method    => "print_expired_holds_stream",
2222     api_name  => "open-ils.circ.captured_holds.expired.print.stream",
2223     stream    => 1
2224 );
2225
2226 sub print_expired_holds_stream {
2227     my ($self, $client, $auth, $params) = @_;
2228
2229     # No need to check specific permissions: we're going to call another method
2230     # that will do that.
2231     my $e = new_editor("authtoken" => $auth);
2232     return $e->die_event unless $e->checkauth;
2233
2234     delete($$params{org_id}) unless (int($$params{org_id}));
2235     delete($$params{limit}) unless (int($$params{limit}));
2236     delete($$params{offset}) unless (int($$params{offset}));
2237     delete($$params{chunk_size}) unless (int($$params{chunk_size}));
2238     delete($$params{chunk_size}) if  ($$params{chunk_size} && $$params{chunk_size} > 50); # keep the size reasonable
2239     $$params{chunk_size} ||= 10;
2240
2241     $$params{org_id} = (defined $$params{org_id}) ? $$params{org_id}: $e->requestor->ws_ou;
2242
2243     my @hold_ids = $self->method_lookup(
2244         "open-ils.circ.captured_holds.id_list.expired_on_shelf.retrieve"
2245     )->run($auth, $params->{"org_id"});
2246
2247     if (!@hold_ids) {
2248         $e->disconnect;
2249         return;
2250     } elsif (defined $U->event_code($hold_ids[0])) {
2251         $e->disconnect;
2252         return $hold_ids[0];
2253     }
2254
2255     $logger->info("about to stream back up to " . scalar(@hold_ids) . " expired holds");
2256
2257     while (@hold_ids) {
2258         my @hid_chunk = splice @hold_ids, 0, $params->{"chunk_size"};
2259
2260         my $result_chunk = $e->json_query({
2261             "select" => {
2262                 "acp" => ["barcode"],
2263                 "au" => [qw/
2264                     first_given_name second_given_name family_name alias
2265                 /],
2266                 "acn" => ["label"],
2267                 "bre" => ["marc"],
2268                 "acpl" => ["name"]
2269             },
2270             "from" => {
2271                 "ahr" => {
2272                     "acp" => {
2273                         "field" => "id", "fkey" => "current_copy",
2274                         "join" => {
2275                             "acn" => {
2276                                 "field" => "id", "fkey" => "call_number",
2277                                 "join" => {
2278                                     "bre" => {
2279                                         "field" => "id", "fkey" => "record"
2280                                     }
2281                                 }
2282                             },
2283                             "acpl" => {"field" => "id", "fkey" => "location"}
2284                         }
2285                     },
2286                     "au" => {"field" => "id", "fkey" => "usr"}
2287                 }
2288             },
2289             "where" => {"+ahr" => {"id" => \@hid_chunk}}
2290         }) or return $e->die_event;
2291         $client->respond($result_chunk);
2292     }
2293
2294     $e->disconnect;
2295     undef;
2296 }
2297
2298 __PACKAGE__->register_method(
2299     method    => "check_title_hold_batch",
2300     api_name  => "open-ils.circ.title_hold.is_possible.batch",
2301     stream    => 1,
2302     signature => {
2303         desc  => '@see open-ils.circ.title_hold.is_possible.batch',
2304         params => [
2305             { desc => 'Authentication token',     type => 'string'},
2306             { desc => 'Array of Hash of named parameters', type => 'array'},
2307         ],
2308         return => {
2309             desc => 'Array of response objects',
2310             type => 'array'
2311         }
2312     }
2313 );
2314
2315 sub check_title_hold_batch {
2316     my($self, $client, $authtoken, $param_list, $oargs) = @_;
2317     foreach (@$param_list) {
2318         my ($res) = $self->method_lookup('open-ils.circ.title_hold.is_possible')->run($authtoken, $_, $oargs);
2319         $client->respond($res);
2320     }
2321     return undef;
2322 }
2323
2324
2325 __PACKAGE__->register_method(
2326     method    => "check_title_hold",
2327     api_name  => "open-ils.circ.title_hold.is_possible",
2328     signature => {
2329         desc  => 'Determines if a hold were to be placed by a given user, ' .
2330              'whether or not said hold would have any potential copies to fulfill it.' .
2331              'The named paramaters of the second argument include: ' .
2332              'patronid, titleid, volume_id, copy_id, mrid, depth, pickup_lib, hold_type, selection_ou. ' .
2333              'See perldoc ' . __PACKAGE__ . ' for more info on these fields.' ,
2334         params => [
2335             { desc => 'Authentication token',     type => 'string'},
2336             { desc => 'Hash of named parameters', type => 'object'},
2337         ],
2338         return => {
2339             desc => 'List of new message IDs (empty if none)',
2340             type => 'array'
2341         }
2342     }
2343 );
2344
2345 =head3 check_title_hold (token, hash)
2346
2347 The named fields in the hash are:
2348
2349  patronid     - ID of the hold recipient  (required)
2350  depth        - hold range depth          (default 0)
2351  pickup_lib   - destination for hold, fallback value for selection_ou
2352  selection_ou - ID of org_unit establishing hard and soft hold boundary settings
2353  issuanceid   - ID of the issuance to be held, required for Issuance level hold
2354  partid       - ID of the monograph part to be held, required for monograph part level hold
2355  titleid      - ID (BRN) of the title to be held, required for Title level hold
2356  volume_id    - required for Volume level hold
2357  copy_id      - required for Copy level hold
2358  mrid         - required for Meta-record level hold
2359  hold_type    - T, C (or R or F), I, V or M for Title, Copy, Issuance, Volume or Meta-record  (default "T")
2360
2361 All key/value pairs are passed on to do_possibility_checks.
2362
2363 =cut
2364
2365 # FIXME: better params checking.  what other params are required, if any?
2366 # FIXME: 3 copies of values confusing: $x, $params->{x} and $params{x}
2367 # FIXME: for example, $depth gets a default value, but then $$params{depth} is still
2368 # used in conditionals, where it may be undefined, causing a warning.
2369 # FIXME: specify proper usage/interaction of selection_ou and pickup_lib
2370
2371 sub check_title_hold {
2372     my( $self, $client, $authtoken, $params ) = @_;
2373     my $e = new_editor(authtoken=>$authtoken);
2374     return $e->event unless $e->checkauth;
2375
2376     my %params       = %$params;
2377     my $depth        = $params{depth}        || 0;
2378     my $selection_ou = $params{selection_ou} || $params{pickup_lib};
2379     my $oargs        = $params{oargs}        || {};
2380
2381     if($oargs->{events}) {
2382         @{$oargs->{events}} = grep { $e->allowed($_ . '.override', $e->requestor->ws_ou); } @{$oargs->{events}};
2383     }
2384
2385
2386     my $patron = $e->retrieve_actor_user($params{patronid})
2387         or return $e->event;
2388
2389     if( $e->requestor->id ne $patron->id ) {
2390         return $e->event unless
2391             $e->allowed('VIEW_HOLD_PERMIT', $patron->home_ou);
2392     }
2393
2394     return OpenILS::Event->new('PATRON_BARRED') if $U->is_true($patron->barred);
2395
2396     my $request_lib = $e->retrieve_actor_org_unit($e->requestor->ws_ou)
2397         or return $e->event;
2398
2399     my $soft_boundary = $U->ou_ancestor_setting_value($selection_ou, OILS_SETTING_HOLD_SOFT_BOUNDARY);
2400     my $hard_boundary = $U->ou_ancestor_setting_value($selection_ou, OILS_SETTING_HOLD_HARD_BOUNDARY);
2401
2402     my @status = ();
2403     my $return_depth = $hard_boundary; # default depth to return on success
2404     if(defined $soft_boundary and $depth < $soft_boundary) {
2405         # work up the tree and as soon as we find a potential copy, use that depth
2406         # also, make sure we don't go past the hard boundary if it exists
2407
2408         # our min boundary is the greater of user-specified boundary or hard boundary
2409         my $min_depth = (defined $hard_boundary and $hard_boundary > $depth) ?
2410             $hard_boundary : $depth;
2411
2412         my $depth = $soft_boundary;
2413         while($depth >= $min_depth) {
2414             $logger->info("performing hold possibility check with soft boundary $depth");
2415             @status = do_possibility_checks($e, $patron, $request_lib, $depth, %params);
2416             if ($status[0]) {
2417                 $return_depth = $depth;
2418                 last;
2419             }
2420             $depth--;
2421         }
2422     } elsif(defined $hard_boundary and $depth < $hard_boundary) {
2423         # there is no soft boundary, enforce the hard boundary if it exists
2424         $logger->info("performing hold possibility check with hard boundary $hard_boundary");
2425         @status = do_possibility_checks($e, $patron, $request_lib, $hard_boundary, %params);
2426     } else {
2427         # no boundaries defined, fall back to user specifed boundary or no boundary
2428         $logger->info("performing hold possibility check with no boundary");
2429         @status = do_possibility_checks($e, $patron, $request_lib, $params{depth}, %params);
2430     }
2431
2432     my $place_unfillable = 0;
2433     $place_unfillable = 1 if $e->allowed('PLACE_UNFILLABLE_HOLD', $e->requestor->ws_ou);
2434
2435     if ($status[0]) {
2436         return {
2437             "success" => 1,
2438             "depth" => $return_depth,
2439             "local_avail" => $status[1]
2440         };
2441     } elsif ($status[2]) {
2442         my $n = scalar @{$status[2]};
2443         return {"success" => 0, "last_event" => $status[2]->[$n - 1], "age_protected_copy" => $status[3], "place_unfillable" => $place_unfillable};
2444     } else {
2445         return {"success" => 0, "age_protected_copy" => $status[3], "place_unfillable" => $place_unfillable};
2446     }
2447 }
2448
2449
2450
2451 sub do_possibility_checks {
2452     my($e, $patron, $request_lib, $depth, %params) = @_;
2453
2454     my $issuanceid   = $params{issuanceid}      || "";
2455     my $partid       = $params{partid}      || "";
2456     my $titleid      = $params{titleid}      || "";
2457     my $volid        = $params{volume_id};
2458     my $copyid       = $params{copy_id};
2459     my $mrid         = $params{mrid}         || "";
2460     my $pickup_lib   = $params{pickup_lib};
2461     my $hold_type    = $params{hold_type}    || 'T';
2462     my $selection_ou = $params{selection_ou} || $pickup_lib;
2463     my $holdable_formats = $params{holdable_formats};
2464     my $oargs        = $params{oargs}        || {};
2465
2466
2467     my $copy;
2468     my $volume;
2469     my $title;
2470
2471     if( $hold_type eq OILS_HOLD_TYPE_FORCE || $hold_type eq OILS_HOLD_TYPE_RECALL || $hold_type eq OILS_HOLD_TYPE_COPY ) {
2472
2473         return $e->event unless $copy   = $e->retrieve_asset_copy($copyid);
2474         return $e->event unless $volume = $e->retrieve_asset_call_number($copy->call_number);
2475         return $e->event unless $title  = $e->retrieve_biblio_record_entry($volume->record);
2476
2477         return (1, 1, []) if( $hold_type eq OILS_HOLD_TYPE_RECALL || $hold_type eq OILS_HOLD_TYPE_FORCE);
2478         return verify_copy_for_hold(
2479             $patron, $e->requestor, $title, $copy, $pickup_lib, $request_lib, $oargs
2480         );
2481
2482     } elsif( $hold_type eq OILS_HOLD_TYPE_VOLUME ) {
2483
2484         return $e->event unless $volume = $e->retrieve_asset_call_number($volid);
2485         return $e->event unless $title  = $e->retrieve_biblio_record_entry($volume->record);
2486
2487         return _check_volume_hold_is_possible(
2488             $volume, $title, $depth, $request_lib, $patron, $e->requestor, $pickup_lib, $selection_ou, $oargs
2489         );
2490
2491     } elsif( $hold_type eq OILS_HOLD_TYPE_TITLE ) {
2492
2493         return _check_title_hold_is_possible(
2494             $titleid, $depth, $request_lib, $patron, $e->requestor, $pickup_lib, $selection_ou, undef, $oargs
2495         );
2496
2497     } elsif( $hold_type eq OILS_HOLD_TYPE_ISSUANCE ) {
2498
2499         return _check_issuance_hold_is_possible(
2500             $issuanceid, $depth, $request_lib, $patron, $e->requestor, $pickup_lib, $selection_ou, $oargs
2501         );
2502
2503     } elsif( $hold_type eq OILS_HOLD_TYPE_MONOPART ) {
2504
2505         return _check_monopart_hold_is_possible(
2506             $partid, $depth, $request_lib, $patron, $e->requestor, $pickup_lib, $selection_ou, $oargs
2507         );
2508
2509     } elsif( $hold_type eq OILS_HOLD_TYPE_METARECORD ) {
2510
2511         # pasing undef as the depth to filtered_records causes the depth
2512         # of the selection_ou to be used, which is not what we want here.
2513         $depth ||= 0;
2514
2515         my ($recs) = __PACKAGE__->method_lookup('open-ils.circ.holds.metarecord.filtered_records')->run($mrid, $holdable_formats, $selection_ou, $depth);
2516         my @status = ();
2517         for my $rec (@$recs) {
2518             @status = _check_title_hold_is_possible(
2519                 $rec, $depth, $request_lib, $patron, $e->requestor, $pickup_lib, $selection_ou, $holdable_formats, $oargs
2520             );
2521             last if $status[0];
2522         }
2523         return @status;
2524     }
2525 #   else { Unrecognized hold_type ! }   # FIXME: return error? or 0?
2526 }
2527
2528 sub MR_filter_records {
2529     my $self = shift;
2530     my $client = shift;
2531     my $m = shift;
2532     my $f = shift;
2533     my $o = shift;
2534     my $d = shift;
2535     my $opac_visible = shift;
2536     
2537     my $org_at_depth = defined($d) ? $U->org_unit_ancestor_at_depth($o, $d) : $o;
2538     return $U->storagereq(
2539         'open-ils.storage.metarecord.filtered_records.atomic', 
2540         $m, $f, $org_at_depth, $opac_visible
2541     );
2542 }
2543 __PACKAGE__->register_method(
2544     method   => 'MR_filter_records',
2545     api_name => 'open-ils.circ.holds.metarecord.filtered_records',
2546 );
2547
2548
2549 my %prox_cache;
2550 sub create_ranged_org_filter {
2551     my($e, $selection_ou, $depth) = @_;
2552
2553     # find the orgs from which this hold may be fulfilled,
2554     # based on the selection_ou and depth
2555
2556     my $top_org = $e->search_actor_org_unit([
2557         {parent_ou => undef},
2558         {flesh=>1, flesh_fields=>{aou=>['ou_type']}}])->[0];
2559     my %org_filter;
2560
2561     return () if $depth == $top_org->ou_type->depth;
2562
2563     my $org_list = $U->storagereq('open-ils.storage.actor.org_unit.descendants.atomic', $selection_ou, $depth);
2564     %org_filter = (circ_lib => []);
2565     push(@{$org_filter{circ_lib}}, $_->id) for @$org_list;
2566
2567     $logger->info("hold org filter at depth $depth and selection_ou ".
2568         "$selection_ou created list of @{$org_filter{circ_lib}}");
2569
2570     return %org_filter;
2571 }
2572
2573
2574 sub _check_title_hold_is_possible {
2575     my( $titleid, $depth, $request_lib, $patron, $requestor, $pickup_lib, $selection_ou, $holdable_formats, $oargs ) = @_;
2576     # $holdable_formats is now unused. We pre-filter the MR's records.
2577
2578     my $e = new_editor();
2579     my %org_filter = create_ranged_org_filter($e, $selection_ou, $depth);
2580
2581     # this monster will grab the id and circ_lib of all of the "holdable" copies for the given record
2582     my $copies = $e->json_query(
2583         {
2584             select => { acp => ['id', 'circ_lib'] },
2585               from => {
2586                 acp => {
2587                     acn => {
2588                         field  => 'id',
2589                         fkey   => 'call_number',
2590                         filter => { record => $titleid }
2591                     },
2592                     acpl => { field => 'id', filter => { holdable => 't'}, fkey => 'location' },
2593                     ccs  => { field => 'id', filter => { holdable => 't'}, fkey => 'status'   },
2594                     acpm => { field => 'target_copy', type => 'left' } # ignore part-linked copies
2595                 }
2596             },
2597             where => {
2598                 '+acp' => { circulate => 't', deleted => 'f', holdable => 't', %org_filter },
2599                 '+acpm' => { target_copy => undef } # ignore part-linked copies
2600             }
2601         }
2602     );
2603
2604     $logger->info("title possible found ".scalar(@$copies)." potential copies");
2605     return (
2606         0, 0, [
2607             new OpenILS::Event(
2608                 "HIGH_LEVEL_HOLD_HAS_NO_COPIES",
2609                 "payload" => {"fail_part" => "no_ultimate_items"}
2610             )
2611         ]
2612     ) unless @$copies;
2613
2614     # -----------------------------------------------------------------------
2615     # sort the copies into buckets based on their circ_lib proximity to
2616     # the patron's home_ou.
2617     # -----------------------------------------------------------------------
2618
2619     my $home_org = $patron->home_ou;
2620     my $req_org = $request_lib->id;
2621
2622     $logger->info("prox cache $home_org " . $prox_cache{$home_org});
2623
2624     $prox_cache{$home_org} =
2625         $e->search_actor_org_unit_proximity({from_org => $home_org})
2626         unless $prox_cache{$home_org};
2627     my $home_prox = $prox_cache{$home_org};
2628
2629     my %buckets;
2630     my %hash = map { ($_->to_org => $_->prox) } @$home_prox;
2631     push( @{$buckets{ $hash{$_->{circ_lib}} } }, $_->{id} ) for @$copies;
2632
2633     my @keys = sort { $a <=> $b } keys %buckets;
2634
2635
2636     if( $home_org ne $req_org ) {
2637       # -----------------------------------------------------------------------
2638       # shove the copies close to the request_lib into the primary buckets
2639       # directly before the farthest away copies.  That way, they are not
2640       # given priority, but they are checked before the farthest copies.
2641       # -----------------------------------------------------------------------
2642         $prox_cache{$req_org} =
2643             $e->search_actor_org_unit_proximity({from_org => $req_org})
2644             unless $prox_cache{$req_org};
2645         my $req_prox = $prox_cache{$req_org};
2646
2647         my %buckets2;
2648         my %hash2 = map { ($_->to_org => $_->prox) } @$req_prox;
2649         push( @{$buckets2{ $hash2{$_->{circ_lib}} } }, $_->{id} ) for @$copies;
2650
2651         my $highest_key = $keys[@keys - 1];  # the farthest prox in the exising buckets
2652         my $new_key = $highest_key - 0.5; # right before the farthest prox
2653         my @keys2   = sort { $a <=> $b } keys %buckets2;
2654         for my $key (@keys2) {
2655             last if $key >= $highest_key;
2656             push( @{$buckets{$new_key}}, $_ ) for @{$buckets2{$key}};
2657         }
2658     }
2659
2660     @keys = sort { $a <=> $b } keys %buckets;
2661
2662     my $title;
2663     my %seen;
2664     my @status;
2665     my $age_protect_only = 0;
2666     OUTER: for my $key (@keys) {
2667       my @cps = @{$buckets{$key}};
2668
2669       $logger->info("looking at " . scalar(@{$buckets{$key}}). " copies in proximity bucket $key");
2670
2671       for my $copyid (@cps) {
2672
2673          next if $seen{$copyid};
2674          $seen{$copyid} = 1; # there could be dupes given the merged buckets
2675          my $copy = $e->retrieve_asset_copy($copyid);
2676          $logger->debug("looking at bucket_key=$key, copy $copyid : circ_lib = " . $copy->circ_lib);
2677
2678          unless($title) { # grab the title if we don't already have it
2679             my $vol = $e->retrieve_asset_call_number(
2680                [ $copy->call_number, { flesh => 1, flesh_fields => { bre => ['fixed_fields'], acn => ['record'] } } ] );
2681             $title = $vol->record;
2682          }
2683
2684          @status = verify_copy_for_hold(
2685             $patron, $requestor, $title, $copy, $pickup_lib, $request_lib, $oargs);
2686
2687          $age_protect_only ||= $status[3];
2688          last OUTER if $status[0];
2689       }
2690     }
2691
2692     $status[3] = $age_protect_only;
2693     return @status;
2694 }
2695
2696 sub _check_issuance_hold_is_possible {
2697     my( $issuanceid, $depth, $request_lib, $patron, $requestor, $pickup_lib, $selection_ou, $oargs ) = @_;
2698
2699     my $e = new_editor();
2700     my %org_filter = create_ranged_org_filter($e, $selection_ou, $depth);
2701
2702     # this monster will grab the id and circ_lib of all of the "holdable" copies for the given record
2703     my $copies = $e->json_query(
2704         {
2705             select => { acp => ['id', 'circ_lib'] },
2706               from => {
2707                 acp => {
2708                     sitem => {
2709                         field  => 'unit',
2710                         fkey   => 'id',
2711                         filter => { issuance => $issuanceid }
2712                     },
2713                     acpl => { field => 'id', filter => { holdable => 't'}, fkey => 'location' },
2714                     ccs  => { field => 'id', filter => { holdable => 't'}, fkey => 'status'   }
2715                 }
2716             },
2717             where => {
2718                 '+acp' => { circulate => 't', deleted => 'f', holdable => 't', %org_filter }
2719             },
2720             distinct => 1
2721         }
2722     );
2723
2724     $logger->info("issuance possible found ".scalar(@$copies)." potential copies");
2725
2726     my $empty_ok;
2727     if (!@$copies) {
2728         $empty_ok = $e->retrieve_config_global_flag('circ.holds.empty_issuance_ok');
2729         $empty_ok = ($empty_ok and $U->is_true($empty_ok->enabled));
2730
2731         return (
2732             0, 0, [
2733                 new OpenILS::Event(
2734                     "HIGH_LEVEL_HOLD_HAS_NO_COPIES",
2735                     "payload" => {"fail_part" => "no_ultimate_items"}
2736                 )
2737             ]
2738         ) unless $empty_ok;
2739
2740         return (1, 0);
2741     }
2742
2743     # -----------------------------------------------------------------------
2744     # sort the copies into buckets based on their circ_lib proximity to
2745     # the patron's home_ou.
2746     # -----------------------------------------------------------------------
2747
2748     my $home_org = $patron->home_ou;
2749     my $req_org = $request_lib->id;
2750
2751     $logger->info("prox cache $home_org " . $prox_cache{$home_org});
2752
2753     $prox_cache{$home_org} =
2754         $e->search_actor_org_unit_proximity({from_org => $home_org})
2755         unless $prox_cache{$home_org};
2756     my $home_prox = $prox_cache{$home_org};
2757
2758     my %buckets;
2759     my %hash = map { ($_->to_org => $_->prox) } @$home_prox;
2760     push( @{$buckets{ $hash{$_->{circ_lib}} } }, $_->{id} ) for @$copies;
2761
2762     my @keys = sort { $a <=> $b } keys %buckets;
2763
2764
2765     if( $home_org ne $req_org ) {
2766       # -----------------------------------------------------------------------
2767       # shove the copies close to the request_lib into the primary buckets
2768       # directly before the farthest away copies.  That way, they are not
2769       # given priority, but they are checked before the farthest copies.
2770       # -----------------------------------------------------------------------
2771         $prox_cache{$req_org} =
2772             $e->search_actor_org_unit_proximity({from_org => $req_org})
2773             unless $prox_cache{$req_org};
2774         my $req_prox = $prox_cache{$req_org};
2775
2776         my %buckets2;
2777         my %hash2 = map { ($_->to_org => $_->prox) } @$req_prox;
2778         push( @{$buckets2{ $hash2{$_->{circ_lib}} } }, $_->{id} ) for @$copies;
2779
2780         my $highest_key = $keys[@keys - 1];  # the farthest prox in the exising buckets
2781         my $new_key = $highest_key - 0.5; # right before the farthest prox
2782         my @keys2   = sort { $a <=> $b } keys %buckets2;
2783         for my $key (@keys2) {
2784             last if $key >= $highest_key;
2785             push( @{$buckets{$new_key}}, $_ ) for @{$buckets2{$key}};
2786         }
2787     }
2788
2789     @keys = sort { $a <=> $b } keys %buckets;
2790
2791     my $title;
2792     my %seen;
2793     my @status;
2794     my $age_protect_only = 0;
2795     OUTER: for my $key (@keys) {
2796       my @cps = @{$buckets{$key}};
2797
2798       $logger->info("looking at " . scalar(@{$buckets{$key}}). " copies in proximity bucket $key");
2799
2800       for my $copyid (@cps) {
2801
2802          next if $seen{$copyid};
2803          $seen{$copyid} = 1; # there could be dupes given the merged buckets
2804          my $copy = $e->retrieve_asset_copy($copyid);
2805          $logger->debug("looking at bucket_key=$key, copy $copyid : circ_lib = " . $copy->circ_lib);
2806
2807          unless($title) { # grab the title if we don't already have it
2808             my $vol = $e->retrieve_asset_call_number(
2809                [ $copy->call_number, { flesh => 1, flesh_fields => { bre => ['fixed_fields'], acn => ['record'] } } ] );
2810             $title = $vol->record;
2811          }
2812
2813          @status = verify_copy_for_hold(
2814             $patron, $requestor, $title, $copy, $pickup_lib, $request_lib, $oargs);
2815
2816          $age_protect_only ||= $status[3];
2817          last OUTER if $status[0];
2818       }
2819     }
2820
2821     if (!$status[0]) {
2822         if (!defined($empty_ok)) {
2823             $empty_ok = $e->retrieve_config_global_flag('circ.holds.empty_issuance_ok');
2824             $empty_ok = ($empty_ok and $U->is_true($empty_ok->enabled));
2825         }
2826
2827         return (1,0) if ($empty_ok);
2828     }
2829     $status[3] = $age_protect_only;
2830     return @status;
2831 }
2832
2833 sub _check_monopart_hold_is_possible {
2834     my( $partid, $depth, $request_lib, $patron, $requestor, $pickup_lib, $selection_ou, $oargs ) = @_;
2835
2836     my $e = new_editor();
2837     my %org_filter = create_ranged_org_filter($e, $selection_ou, $depth);
2838
2839     # this monster will grab the id and circ_lib of all of the "holdable" copies for the given record
2840     my $copies = $e->json_query(
2841         {
2842             select => { acp => ['id', 'circ_lib'] },
2843               from => {
2844                 acp => {
2845                     acpm => {
2846                         field  => 'target_copy',
2847                         fkey   => 'id',
2848                         filter => { part => $partid }
2849                     },
2850                     acpl => { field => 'id', filter => { holdable => 't'}, fkey => 'location' },
2851                     ccs  => { field => 'id', filter => { holdable => 't'}, fkey => 'status'   }
2852                 }
2853             },
2854             where => {
2855                 '+acp' => { circulate => 't', deleted => 'f', holdable => 't', %org_filter }
2856             },
2857             distinct => 1
2858         }
2859     );
2860
2861     $logger->info("monopart possible found ".scalar(@$copies)." potential copies");
2862
2863     my $empty_ok;
2864     if (!@$copies) {
2865         $empty_ok = $e->retrieve_config_global_flag('circ.holds.empty_part_ok');
2866         $empty_ok = ($empty_ok and $U->is_true($empty_ok->enabled));
2867
2868         return (
2869             0, 0, [
2870                 new OpenILS::Event(
2871                     "HIGH_LEVEL_HOLD_HAS_NO_COPIES",
2872                     "payload" => {"fail_part" => "no_ultimate_items"}
2873                 )
2874             ]
2875         ) unless $empty_ok;
2876
2877         return (1, 0);
2878     }
2879
2880     # -----------------------------------------------------------------------
2881     # sort the copies into buckets based on their circ_lib proximity to
2882     # the patron's home_ou.
2883     # -----------------------------------------------------------------------
2884
2885     my $home_org = $patron->home_ou;
2886     my $req_org = $request_lib->id;
2887
2888     $logger->info("prox cache $home_org " . $prox_cache{$home_org});
2889
2890     $prox_cache{$home_org} =
2891         $e->search_actor_org_unit_proximity({from_org => $home_org})
2892         unless $prox_cache{$home_org};
2893     my $home_prox = $prox_cache{$home_org};
2894
2895     my %buckets;
2896     my %hash = map { ($_->to_org => $_->prox) } @$home_prox;
2897     push( @{$buckets{ $hash{$_->{circ_lib}} } }, $_->{id} ) for @$copies;
2898
2899     my @keys = sort { $a <=> $b } keys %buckets;
2900
2901
2902     if( $home_org ne $req_org ) {
2903       # -----------------------------------------------------------------------
2904       # shove the copies close to the request_lib into the primary buckets
2905       # directly before the farthest away copies.  That way, they are not
2906       # given priority, but they are checked before the farthest copies.
2907       # -----------------------------------------------------------------------
2908         $prox_cache{$req_org} =
2909             $e->search_actor_org_unit_proximity({from_org => $req_org})
2910             unless $prox_cache{$req_org};
2911         my $req_prox = $prox_cache{$req_org};
2912
2913         my %buckets2;
2914         my %hash2 = map { ($_->to_org => $_->prox) } @$req_prox;
2915         push( @{$buckets2{ $hash2{$_->{circ_lib}} } }, $_->{id} ) for @$copies;
2916
2917         my $highest_key = $keys[@keys - 1];  # the farthest prox in the exising buckets
2918         my $new_key = $highest_key - 0.5; # right before the farthest prox
2919         my @keys2   = sort { $a <=> $b } keys %buckets2;
2920         for my $key (@keys2) {
2921             last if $key >= $highest_key;
2922             push( @{$buckets{$new_key}}, $_ ) for @{$buckets2{$key}};
2923         }
2924     }
2925
2926     @keys = sort { $a <=> $b } keys %buckets;
2927
2928     my $title;
2929     my %seen;
2930     my @status;
2931     my $age_protect_only = 0;
2932     OUTER: for my $key (@keys) {
2933       my @cps = @{$buckets{$key}};
2934
2935       $logger->info("looking at " . scalar(@{$buckets{$key}}). " copies in proximity bucket $key");
2936
2937       for my $copyid (@cps) {
2938
2939          next if $seen{$copyid};
2940          $seen{$copyid} = 1; # there could be dupes given the merged buckets
2941          my $copy = $e->retrieve_asset_copy($copyid);
2942          $logger->debug("looking at bucket_key=$key, copy $copyid : circ_lib = " . $copy->circ_lib);
2943
2944          unless($title) { # grab the title if we don't already have it
2945             my $vol = $e->retrieve_asset_call_number(
2946                [ $copy->call_number, { flesh => 1, flesh_fields => { bre => ['fixed_fields'], acn => ['record'] } } ] );
2947             $title = $vol->record;
2948          }
2949
2950          @status = verify_copy_for_hold(
2951             $patron, $requestor, $title, $copy, $pickup_lib, $request_lib, $oargs);
2952
2953          $age_protect_only ||= $status[3];
2954          last OUTER if $status[0];
2955       }
2956     }
2957
2958     if (!$status[0]) {
2959         if (!defined($empty_ok)) {
2960             $empty_ok = $e->retrieve_config_global_flag('circ.holds.empty_part_ok');
2961             $empty_ok = ($empty_ok and $U->is_true($empty_ok->enabled));
2962         }
2963
2964         return (1,0) if ($empty_ok);
2965     }
2966     $status[3] = $age_protect_only;
2967     return @status;
2968 }
2969
2970
2971 sub _check_volume_hold_is_possible {
2972     my( $vol, $title, $depth, $request_lib, $patron, $requestor, $pickup_lib, $selection_ou, $oargs ) = @_;
2973     my %org_filter = create_ranged_org_filter(new_editor(), $selection_ou, $depth);
2974     my $copies = new_editor->search_asset_copy({call_number => $vol->id, %org_filter});
2975     $logger->info("checking possibility of volume hold for volume ".$vol->id);
2976
2977     my $filter_copies = [];
2978     for my $copy (@$copies) {
2979         # ignore part-mapped copies for regular volume level holds
2980         push(@$filter_copies, $copy) unless
2981             new_editor->search_asset_copy_part_map({target_copy => $copy->id})->[0];
2982     }
2983     $copies = $filter_copies;
2984
2985     return (
2986         0, 0, [
2987             new OpenILS::Event(
2988                 "HIGH_LEVEL_HOLD_HAS_NO_COPIES",
2989                 "payload" => {"fail_part" => "no_ultimate_items"}
2990             )
2991         ]
2992     ) unless @$copies;
2993
2994     my @status;
2995     my $age_protect_only = 0;
2996     for my $copy ( @$copies ) {
2997         @status = verify_copy_for_hold(
2998             $patron, $requestor, $title, $copy, $pickup_lib, $request_lib, $oargs );
2999         $age_protect_only ||= $status[3];
3000         last if $status[0];
3001     }
3002     $status[3] = $age_protect_only;
3003     return @status;
3004 }
3005
3006
3007
3008 sub verify_copy_for_hold {
3009     my( $patron, $requestor, $title, $copy, $pickup_lib, $request_lib, $oargs ) = @_;
3010     # $oargs should be undef unless we're overriding.
3011     $logger->info("checking possibility of copy in hold request for copy ".$copy->id);
3012     my $permitted = OpenILS::Utils::PermitHold::permit_copy_hold(
3013         {
3014             patron           => $patron,
3015             requestor        => $requestor,
3016             copy             => $copy,
3017             title            => $title,
3018             title_descriptor => $title->fixed_fields,
3019             pickup_lib       => $pickup_lib,
3020             request_lib      => $request_lib,
3021             new_hold         => 1,
3022             show_event_list  => 1
3023         }
3024     );
3025
3026     # Check for override permissions on events.
3027     if ($oargs && $permitted && scalar @$permitted) {
3028         # Remove the events from permitted that we can override.
3029         if ($oargs->{events}) {
3030             foreach my $evt (@{$oargs->{events}}) {
3031                 $permitted = [grep {$_->{textcode} ne $evt} @{$permitted}];
3032             }
3033         }
3034         # Now, we handle the override all case by checking remaining
3035         # events against override permissions.
3036         if (scalar @$permitted && $oargs->{all}) {
3037             # Pre-set events and failed members of oargs to empty
3038             # arrays, if they are not set, yet.
3039             $oargs->{events} = [] unless ($oargs->{events});
3040             $oargs->{failed} = [] unless ($oargs->{failed});
3041             # When we're done with these checks, we swap permitted
3042             # with a reference to @disallowed.
3043             my @disallowed = ();
3044             foreach my $evt (@{$permitted}) {
3045                 # Check if we've already seen the event in this
3046                 # session and it failed.
3047                 if (grep {$_ eq $evt->{textcode}} @{$oargs->{failed}}) {
3048                     push(@disallowed, $evt);
3049                 } else {
3050                     # We have to check if the requestor has the
3051                     # override permission.
3052
3053                     # AppUtils::check_user_perms returns the perm if
3054                     # the user doesn't have it, undef if they do.
3055                     if ($apputils->check_user_perms($requestor->id, $requestor->ws_ou, $evt->{textcode} . '.override')) {
3056                         push(@disallowed, $evt);
3057                         push(@{$oargs->{failed}}, $evt->{textcode});
3058                     } else {
3059                         push(@{$oargs->{events}}, $evt->{textcode});
3060                     }
3061                 }
3062             }
3063             $permitted = \@disallowed;
3064         }
3065     }
3066
3067     my $age_protect_only = 0;
3068     if (@$permitted == 1 && @$permitted[0]->{textcode} eq 'ITEM_AGE_PROTECTED') {
3069         $age_protect_only = 1;
3070     }
3071
3072     return (
3073         (not scalar @$permitted), # true if permitted is an empty arrayref
3074         (   # XXX This test is of very dubious value; someone should figure
3075             # out what if anything is checking this value
3076             ($copy->circ_lib == $pickup_lib) and
3077             ($copy->status == OILS_COPY_STATUS_AVAILABLE)
3078         ),
3079         $permitted,
3080         $age_protect_only
3081     );
3082 }
3083
3084
3085
3086 sub find_nearest_permitted_hold {
3087
3088     my $class  = shift;
3089     my $editor = shift;     # CStoreEditor object
3090     my $copy   = shift;     # copy to target
3091     my $user   = shift;     # staff
3092     my $check_only = shift; # do no updates, just see if the copy could fulfill a hold
3093
3094     my $evt = OpenILS::Event->new('ACTION_HOLD_REQUEST_NOT_FOUND');
3095
3096     my $bc = $copy->barcode;
3097
3098     # find any existing holds that already target this copy
3099     my $old_holds = $editor->search_action_hold_request(
3100         {    current_copy => $copy->id,
3101             cancel_time  => undef,
3102             capture_time => undef
3103         }
3104     );
3105
3106     my $hold_stall_interval = $U->ou_ancestor_setting_value($user->ws_ou, OILS_SETTING_HOLD_SOFT_STALL);
3107
3108     $logger->info("circulator: searching for best hold at org ".$user->ws_ou.
3109         " and copy $bc with a hold stalling interval of ". ($hold_stall_interval || "(none)"));
3110
3111     my $fifo = $U->ou_ancestor_setting_value($user->ws_ou, 'circ.holds_fifo');
3112
3113     # the nearest_hold API call now needs this
3114     $copy->call_number($editor->retrieve_asset_call_number($copy->call_number))
3115         unless ref $copy->call_number;
3116
3117     # search for what should be the best holds for this copy to fulfill
3118     my $best_holds = $U->storagereq(
3119         "open-ils.storage.action.hold_request.nearest_hold.atomic", 
3120         $user->ws_ou, $copy, 100, $hold_stall_interval, $fifo );
3121
3122     # Add any pre-targeted holds to the list too? Unless they are already there, anyway.
3123     if ($old_holds) {
3124         for my $holdid (@$old_holds) {
3125             next unless $holdid;
3126             push(@$best_holds, $holdid) unless ( grep { ''.$holdid eq ''.$_ } @$best_holds );
3127         }
3128     }
3129
3130     unless(@$best_holds) {
3131         $logger->info("circulator: no suitable holds found for copy $bc");
3132         return (undef, $evt);
3133     }
3134
3135
3136     my $best_hold;
3137
3138     # for each potential hold, we have to run the permit script
3139     # to make sure the hold is actually permitted.
3140     my %reqr_cache;
3141     my %org_cache;
3142     for my $holdid (@$best_holds) {
3143         next unless $holdid;
3144         $logger->info("circulator: checking if hold $holdid is permitted for copy $bc");
3145
3146         my $hold = $editor->retrieve_action_hold_request($holdid) or next;
3147         # Force and recall holds bypass all rules
3148         if ($hold->hold_type eq 'R' || $hold->hold_type eq 'F') {
3149             $best_hold = $hold;
3150             last;
3151         }
3152         my $reqr = $reqr_cache{$hold->requestor} || $editor->retrieve_actor_user($hold->requestor);
3153         my $rlib = $org_cache{$hold->request_lib} || $editor->retrieve_actor_org_unit($hold->request_lib);
3154
3155         $reqr_cache{$hold->requestor} = $reqr;
3156         $org_cache{$hold->request_lib} = $rlib;
3157
3158         # see if this hold is permitted
3159         my $permitted = OpenILS::Utils::PermitHold::permit_copy_hold(
3160             {
3161                 patron_id   => $hold->usr,
3162                 requestor   => $reqr,
3163                 copy        => $copy,
3164                 pickup_lib  => $hold->pickup_lib,
3165                 request_lib => $rlib,
3166                 retarget    => 1
3167             }
3168         );
3169
3170         if( $permitted ) {
3171             $best_hold = $hold;
3172             last;
3173         }
3174     }
3175
3176
3177     unless( $best_hold ) { # no "good" permitted holds were found
3178         # we got nuthin
3179         $logger->info("circulator: no suitable holds found for copy $bc");
3180         return (undef, $evt);
3181     }
3182
3183     $logger->info("circulator: best hold ".$best_hold->id." found for copy $bc");
3184
3185     # indicate a permitted hold was found
3186     return $best_hold if $check_only;
3187
3188     # we've found a permitted hold.  we need to "grab" the copy
3189     # to prevent re-targeted holds (next part) from re-grabbing the copy
3190     $best_hold->current_copy($copy->id);
3191     $editor->update_action_hold_request($best_hold)
3192         or return (undef, $editor->event);
3193
3194
3195     my @retarget;
3196
3197     # re-target any other holds that already target this copy
3198     for my $old_hold (@$old_holds) {
3199         next if $old_hold->id eq $best_hold->id; # don't re-target the hold we want
3200         $logger->info("circulator: clearing current_copy and prev_check_time on hold ".
3201             $old_hold->id." after a better hold [".$best_hold->id."] was found");
3202         $old_hold->clear_current_copy;
3203         $old_hold->clear_prev_check_time;
3204         $editor->update_action_hold_request($old_hold)
3205             or return (undef, $editor->event);
3206         push(@retarget, $old_hold->id);
3207     }
3208
3209     return ($best_hold, undef, (@retarget) ? \@retarget : undef);
3210 }
3211
3212
3213
3214
3215
3216
3217 __PACKAGE__->register_method(
3218     method   => 'all_rec_holds',
3219     api_name => 'open-ils.circ.holds.retrieve_all_from_title',
3220 );
3221
3222 sub all_rec_holds {
3223     my( $self, $conn, $auth, $title_id, $args ) = @_;
3224
3225     my $e = new_editor(authtoken=>$auth);
3226     $e->checkauth or return $e->event;
3227     $e->allowed('VIEW_HOLD') or return $e->event;
3228
3229     $args ||= {};
3230     $args->{fulfillment_time} = undef; #  we don't want to see old fulfilled holds
3231     $args->{cancel_time} = undef;
3232
3233     my $resp = {
3234           metarecord_holds => []
3235         , title_holds      => []
3236         , volume_holds     => []
3237         , copy_holds       => []
3238         , recall_holds     => []
3239         , force_holds      => []
3240         , part_holds       => []
3241         , issuance_holds   => []
3242     };
3243
3244     my $mr_map = $e->search_metabib_metarecord_source_map({source => $title_id})->[0];
3245     if($mr_map) {
3246         $resp->{metarecord_holds} = $e->search_action_hold_request(
3247             {   hold_type => OILS_HOLD_TYPE_METARECORD,
3248                 target => $mr_map->metarecord,
3249                 %$args
3250             }, {idlist => 1}
3251         );
3252     }
3253
3254     $resp->{title_holds} = $e->search_action_hold_request(
3255         {
3256             hold_type => OILS_HOLD_TYPE_TITLE,
3257             target => $title_id,
3258             %$args
3259         }, {idlist=>1} );
3260
3261     my $parts = $e->search_biblio_monograph_part(
3262         {
3263             record => $title_id
3264         }, {idlist=>1} );
3265
3266     if (@$parts) {
3267         $resp->{part_holds} = $e->search_action_hold_request(
3268             {
3269                 hold_type => OILS_HOLD_TYPE_MONOPART,
3270                 target => $parts,
3271                 %$args
3272             }, {idlist=>1} );
3273     }
3274
3275     my $subs = $e->search_serial_subscription(
3276         { record_entry => $title_id }, {idlist=>1});
3277
3278     if (@$subs) {
3279         my $issuances = $e->search_serial_issuance(
3280             {subscription => $subs}, {idlist=>1}
3281         );
3282
3283         if (@$issuances) {
3284             $resp->{issuance_holds} = $e->search_action_hold_request(
3285                 {
3286                     hold_type => OILS_HOLD_TYPE_ISSUANCE,
3287                     target => $issuances,
3288                     %$args
3289                 }, {idlist=>1}
3290             );
3291         }
3292     }
3293
3294     my $vols = $e->search_asset_call_number(
3295         { record => $title_id, deleted => 'f' }, {idlist=>1});
3296
3297     return $resp unless @$vols;
3298
3299     $resp->{volume_holds} = $e->search_action_hold_request(
3300         {
3301             hold_type => OILS_HOLD_TYPE_VOLUME,
3302             target => $vols,
3303             %$args },
3304         {idlist=>1} );
3305
3306     my $copies = $e->search_asset_copy(
3307         { call_number => $vols, deleted => 'f' }, {idlist=>1});
3308
3309     return $resp unless @$copies;
3310
3311     $resp->{copy_holds} = $e->search_action_hold_request(
3312         {
3313             hold_type => OILS_HOLD_TYPE_COPY,
3314             target => $copies,
3315             %$args },
3316         {idlist=>1} );
3317
3318     $resp->{recall_holds} = $e->search_action_hold_request(
3319         {
3320             hold_type => OILS_HOLD_TYPE_RECALL,
3321             target => $copies,
3322             %$args },
3323         {idlist=>1} );
3324
3325     $resp->{force_holds} = $e->search_action_hold_request(
3326         {
3327             hold_type => OILS_HOLD_TYPE_FORCE,
3328             target => $copies,
3329             %$args },
3330         {idlist=>1} );
3331
3332     return $resp;
3333 }
3334
3335
3336
3337
3338
3339 __PACKAGE__->register_method(
3340     method        => 'uber_hold',
3341     authoritative => 1,
3342     api_name      => 'open-ils.circ.hold.details.retrieve'
3343 );
3344
3345 sub uber_hold {
3346     my($self, $client, $auth, $hold_id, $args) = @_;
3347     my $e = new_editor(authtoken=>$auth);
3348     $e->checkauth or return $e->event;
3349     return uber_hold_impl($e, $hold_id, $args);
3350 }
3351
3352 __PACKAGE__->register_method(
3353     method        => 'batch_uber_hold',
3354     authoritative => 1,
3355     stream        => 1,
3356     api_name      => 'open-ils.circ.hold.details.batch.retrieve'
3357 );
3358
3359 sub batch_uber_hold {
3360     my($self, $client, $auth, $hold_ids, $args) = @_;
3361     my $e = new_editor(authtoken=>$auth);
3362     $e->checkauth or return $e->event;
3363     $client->respond(uber_hold_impl($e, $_, $args)) for @$hold_ids;
3364     return undef;
3365 }
3366
3367 sub uber_hold_impl {
3368     my($e, $hold_id, $args) = @_;
3369     $args ||= {};
3370
3371     my $hold = $e->retrieve_action_hold_request(
3372         [
3373             $hold_id,
3374             {
3375                 flesh => 1,
3376                 flesh_fields => { ahr => [ 'current_copy', 'usr', 'notes' ] }
3377             }
3378         ]
3379     ) or return $e->event;
3380
3381     if($hold->usr->id ne $e->requestor->id) {
3382         # caller is asking for someone else's hold
3383         $e->allowed('VIEW_HOLD') or return $e->event;
3384         $hold->notes( # filter out any non-staff ("private") notes (unless marked as public)
3385             [ grep { $U->is_true($_->staff) or $U->is_true($_->pub) } @{$hold->notes} ] );
3386
3387     } else {
3388         # caller is asking for own hold, but may not have permission to view staff notes
3389         unless($e->allowed('VIEW_HOLD')) {
3390             $hold->notes( # filter out any staff notes (unless marked as public)
3391                 [ grep { !$U->is_true($_->staff) or $U->is_true($_->pub) } @{$hold->notes} ] );
3392         }
3393     }
3394
3395     my $user = $hold->usr;
3396     $hold->usr($user->id);
3397
3398
3399     my( $mvr, $volume, $copy, $issuance, $part, $bre ) = find_hold_mvr($e, $hold, $args->{suppress_mvr});
3400
3401     flesh_hold_notices([$hold], $e) unless $args->{suppress_notices};
3402     flesh_hold_transits([$hold]) unless $args->{suppress_transits};
3403
3404     my $details = retrieve_hold_queue_status_impl($e, $hold);
3405
3406     my $resp = {
3407         hold    => $hold,
3408         bre_id  => $bre->id,
3409         ($copy     ? (copy           => $copy)     : ()),
3410         ($volume   ? (volume         => $volume)   : ()),
3411         ($issuance ? (issuance       => $issuance) : ()),
3412         ($part     ? (part           => $part)     : ()),
3413         ($args->{include_bre}  ?  (bre => $bre)    : ()),
3414         ($args->{suppress_mvr} ?  () : (mvr => $mvr)),
3415         %$details
3416     };
3417
3418     $resp->{copy}->location(
3419         $e->retrieve_asset_copy_location($resp->{copy}->location))
3420         if $resp->{copy} and $args->{flesh_acpl};
3421
3422     unless($args->{suppress_patron_details}) {
3423         my $card = $e->retrieve_actor_card($user->card) or return $e->event;
3424         $resp->{patron_first}   = $user->first_given_name,
3425         $resp->{patron_last}    = $user->family_name,
3426         $resp->{patron_barcode} = $card->barcode,
3427         $resp->{patron_alias}   = $user->alias,
3428     };
3429
3430     return $resp;
3431 }
3432
3433
3434
3435 # -----------------------------------------------------
3436 # Returns the MVR object that represents what the
3437 # hold is all about
3438 # -----------------------------------------------------
3439 sub find_hold_mvr {
3440     my( $e, $hold, $no_mvr ) = @_;
3441
3442     my $tid;
3443     my $copy;
3444     my $volume;
3445     my $issuance;
3446     my $part;
3447
3448     if( $hold->hold_type eq OILS_HOLD_TYPE_METARECORD ) {
3449         my $mr = $e->retrieve_metabib_metarecord($hold->target)
3450             or return $e->event;
3451         $tid = $mr->master_record;
3452
3453     } elsif( $hold->hold_type eq OILS_HOLD_TYPE_TITLE ) {
3454         $tid = $hold->target;
3455
3456     } elsif( $hold->hold_type eq OILS_HOLD_TYPE_VOLUME ) {
3457         $volume = $e->retrieve_asset_call_number($hold->target)
3458             or return $e->event;
3459         $tid = $volume->record;
3460
3461     } elsif( $hold->hold_type eq OILS_HOLD_TYPE_ISSUANCE ) {
3462         $issuance = $e->retrieve_serial_issuance([
3463             $hold->target,
3464             {flesh => 1, flesh_fields => {siss => [ qw/subscription/ ]}}
3465         ]) or return $e->event;
3466
3467         $tid = $issuance->subscription->record_entry;
3468
3469     } elsif( $hold->hold_type eq OILS_HOLD_TYPE_MONOPART ) {
3470         $part = $e->retrieve_biblio_monograph_part([
3471             $hold->target
3472         ]) or return $e->event;
3473
3474         $tid = $part->record;
3475
3476     } 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 ) {
3477         $copy = $e->retrieve_asset_copy([
3478             $hold->target,
3479             {flesh => 1, flesh_fields => {acp => ['call_number']}}
3480         ]) or return $e->event;
3481
3482         $volume = $copy->call_number;
3483         $tid = $volume->record;
3484     }
3485
3486     if(!$copy and ref $hold->current_copy ) {
3487         $copy = $hold->current_copy;
3488         $hold->current_copy($copy->id);
3489     }
3490
3491     if(!$volume and $copy) {
3492         $volume = $e->retrieve_asset_call_number($copy->call_number);
3493     }
3494
3495     # TODO return metarcord mvr for M holds
3496     my $title = $e->retrieve_biblio_record_entry($tid);
3497     return ( ($no_mvr) ? undef : $U->record_to_mvr($title), $volume, $copy, $issuance, $part, $title );
3498 }
3499
3500 __PACKAGE__->register_method(
3501     method    => 'clear_shelf_cache',
3502     api_name  => 'open-ils.circ.hold.clear_shelf.get_cache',
3503     stream    => 1,
3504     signature => {
3505         desc => q/
3506             Returns the holds processed with the given cache key
3507         /
3508     }
3509 );
3510
3511 sub clear_shelf_cache {
3512     my($self, $client, $auth, $cache_key, $chunk_size) = @_;
3513     my $e = new_editor(authtoken => $auth, xact => 1);
3514     return $e->die_event unless $e->checkauth and $e->allowed('VIEW_HOLD');
3515
3516     $chunk_size ||= 25;
3517     my $hold_data = OpenSRF::Utils::Cache->new('global')->get_cache($cache_key);
3518
3519     if (!$hold_data) {
3520         $logger->info("no hold data found in cache"); # XXX TODO return event
3521         $e->rollback;
3522         return undef;
3523     }
3524
3525     my $maximum = 0;
3526     foreach (keys %$hold_data) {
3527         $maximum += scalar(@{ $hold_data->{$_} });
3528     }
3529     $client->respond({"maximum" => $maximum, "progress" => 0});
3530
3531     for my $action (sort keys %$hold_data) {
3532         while (@{$hold_data->{$action}}) {
3533             my @hid_chunk = splice @{$hold_data->{$action}}, 0, $chunk_size;
3534
3535             my $result_chunk = $e->json_query({
3536                 "select" => {
3537                     "acp" => ["barcode"],
3538                     "au" => [qw/
3539                         first_given_name second_given_name family_name alias
3540                     /],
3541                     "acn" => ["label"],
3542                     "acnp" => [{column => "label", alias => "prefix"}],
3543                     "acns" => [{column => "label", alias => "suffix"}],
3544                     "bre" => ["marc"],
3545                     "acpl" => ["name"],
3546                     "ahr" => ["id"]
3547                 },
3548                 "from" => {
3549                     "ahr" => {
3550                         "acp" => {
3551                             "field" => "id", "fkey" => "current_copy",
3552                             "join" => {
3553                                 "acn" => {
3554                                     "field" => "id", "fkey" => "call_number",
3555                                     "join" => {
3556                                         "bre" => {
3557                                             "field" => "id", "fkey" => "record"
3558                                         },
3559                                         "acnp" => {
3560                                             "field" => "id", "fkey" => "prefix"
3561                                         },
3562                                         "acns" => {
3563                                             "field" => "id", "fkey" => "suffix"
3564                                         }
3565                                     }
3566                                 },
3567                                 "acpl" => {"field" => "id", "fkey" => "location"}
3568                             }
3569                         },
3570                         "au" => {"field" => "id", "fkey" => "usr"}
3571                     }
3572                 },
3573                 "where" => {"+ahr" => {"id" => \@hid_chunk}}
3574             }, {"substream" => 1}) or return $e->die_event;
3575
3576             $client->respond([
3577                 map {
3578                     +{"action" => $action, "hold_details" => $_}
3579                 } @$result_chunk
3580             ]);
3581         }
3582     }
3583
3584     $e->rollback;
3585     return undef;
3586 }
3587
3588
3589 __PACKAGE__->register_method(
3590     method    => 'clear_shelf_process',
3591     stream    => 1,
3592     api_name  => 'open-ils.circ.hold.clear_shelf.process',
3593     signature => {
3594         desc => q/
3595             1. Find all holds that have expired on the holds shelf
3596             2. Cancel the holds
3597             3. If a clear-shelf status is configured, put targeted copies into this status
3598             4. Divide copies into 3 groups: items to transit, items to reshelve, and items
3599                 that are needed for holds.  No subsequent action is taken on the holds
3600                 or items after grouping.
3601         /
3602     }
3603 );
3604
3605 sub clear_shelf_process {
3606     my($self, $client, $auth, $org_id, $match_copy) = @_;
3607
3608     my $e = new_editor(authtoken=>$auth);
3609     $e->checkauth or return $e->die_event;
3610     my $cache = OpenSRF::Utils::Cache->new('global');
3611
3612     $org_id ||= $e->requestor->ws_ou;
3613     $e->allowed('UPDATE_HOLD', $org_id) or return $e->die_event;
3614
3615     my $copy_status = $U->ou_ancestor_setting_value($org_id, 'circ.holds.clear_shelf.copy_status');
3616
3617     my @hold_ids = $self->method_lookup(
3618         "open-ils.circ.captured_holds.id_list.expired_on_shelf.retrieve"
3619     )->run($auth, $org_id, $match_copy);
3620
3621     $e->xact_begin;
3622
3623     my @holds;
3624     my @canceled_holds; # newly canceled holds
3625     my $chunk_size = 25; # chunked status updates
3626     my $counter = 0;
3627     for my $hold_id (@hold_ids) {
3628
3629         $logger->info("Clear shelf processing hold $hold_id");
3630
3631         my $hold = $e->retrieve_action_hold_request([
3632             $hold_id, {
3633                 flesh => 1,
3634                 flesh_fields => {ahr => ['current_copy']}
3635             }
3636         ]);
3637
3638         if (!$hold->cancel_time) { # may be canceled but still on the holds shelf
3639             $hold->cancel_time('now');
3640             $hold->cancel_cause(2); # Hold Shelf expiration
3641             $e->update_action_hold_request($hold) or return $e->die_event;
3642             delete_hold_copy_maps($self, $e, $hold->id) and return $e->die_event;
3643             push(@canceled_holds, $hold_id);
3644         }
3645
3646         my $copy = $hold->current_copy;
3647
3648         if($copy_status or $copy_status == 0) {
3649             # if a clear-shelf copy status is defined, update the copy
3650             $copy->status($copy_status);
3651             $copy->edit_date('now');
3652             $copy->editor($e->requestor->id);
3653             $e->update_asset_copy($copy) or return $e->die_event;
3654         }
3655
3656         push(@holds, $hold);
3657         $client->respond({maximum => scalar(@holds), progress => $counter}) if ( (++$counter % $chunk_size) == 0);
3658     }
3659
3660     if ($e->commit) {
3661
3662         my %cache_data = (
3663             hold => [],
3664             transit => [],
3665             shelf => [],
3666             pl_changed => pickup_lib_changed_on_shelf_holds($e, $org_id, \@hold_ids)
3667         );
3668
3669         for my $hold (@holds) {
3670
3671             my $copy = $hold->current_copy;
3672             my ($alt_hold) = __PACKAGE__->find_nearest_permitted_hold($e, $copy, $e->requestor, 1);
3673
3674             if($alt_hold and !$match_copy) {
3675
3676                 push(@{$cache_data{hold}}, $hold->id); # copy is needed for a hold
3677
3678             } elsif($copy->circ_lib != $e->requestor->ws_ou) {
3679
3680                 push(@{$cache_data{transit}}, $hold->id); # copy needs to transit
3681
3682             } else {
3683
3684                 push(@{$cache_data{shelf}}, $hold->id); # copy needs to go back to the shelf
3685             }
3686         }
3687
3688         my $cache_key = md5_hex(time . $$ . rand());
3689         $logger->info("clear_shelf_cache: storing under $cache_key");
3690         $cache->put_cache($cache_key, \%cache_data, 7200); # TODO: 2 hours.  configurable?
3691
3692         # tell the client we're done
3693         $client->respond_complete({cache_key => $cache_key});
3694
3695         # ------------
3696         # fire off the hold cancelation trigger and wait for response so don't flood the service
3697
3698         # refetch the holds to pick up the caclulated cancel_time,
3699         # which may be needed by Action/Trigger
3700         $e->xact_begin;
3701         my $updated_holds = [];
3702         $updated_holds = $e->search_action_hold_request({id => \@canceled_holds}, {substream => 1}) if (@canceled_holds > 0);
3703         $e->rollback;
3704
3705         $U->create_events_for_hook(
3706             'hold_request.cancel.expire_holds_shelf',
3707             $_, $org_id, undef, undef, 1) for @$updated_holds;
3708
3709     } else {
3710         # tell the client we're done
3711         $client->respond_complete;
3712     }
3713 }
3714
3715 # returns IDs for holds that are on the holds shelf but 
3716 # have had their pickup_libs change while on the shelf.
3717 sub pickup_lib_changed_on_shelf_holds {
3718     my $e = shift;
3719     my $org_id = shift;
3720     my $ignore_holds = shift;
3721     $ignore_holds = [$ignore_holds] if !ref($ignore_holds);
3722
3723     my $query = {
3724         select => { alhr => ['id'] },
3725         from   => {
3726             alhr => {
3727                 acp => {
3728                     field => 'id',
3729                     fkey  => 'current_copy'
3730                 },
3731             }
3732         },
3733         where => {
3734             '+acp' => { status => OILS_COPY_STATUS_ON_HOLDS_SHELF },
3735             '+alhr' => {
3736                 capture_time     => { "!=" => undef },
3737                 fulfillment_time => undef,
3738                 current_shelf_lib => $org_id,
3739                 pickup_lib => {'!='  => {'+alhr' => 'current_shelf_lib'}}
3740             }
3741         }
3742     };
3743
3744     $query->{where}->{'+alhr'}->{id} =
3745         {'not in' => $ignore_holds} if @$ignore_holds;
3746
3747     my $hold_ids = $e->json_query($query);
3748     return [ map { $_->{id} } @$hold_ids ];
3749 }
3750
3751 __PACKAGE__->register_method(
3752     method    => 'usr_hold_summary',
3753     api_name  => 'open-ils.circ.holds.user_summary',
3754     signature => q/
3755         Returns a summary of holds statuses for a given user
3756     /
3757 );
3758
3759 sub usr_hold_summary {
3760     my($self, $conn, $auth, $user_id) = @_;
3761
3762     my $e = new_editor(authtoken=>$auth);
3763     $e->checkauth or return $e->event;
3764     $e->allowed('VIEW_HOLD') or return $e->event;
3765
3766     my $holds = $e->search_action_hold_request(
3767         {
3768             usr =>  $user_id ,
3769             fulfillment_time => undef,
3770             cancel_time      => undef,
3771         }
3772     );
3773
3774     my %summary = (1 => 0, 2 => 0, 3 => 0, 4 => 0);
3775     $summary{_hold_status($e, $_)} += 1 for @$holds;
3776     return \%summary;
3777 }
3778
3779
3780
3781 __PACKAGE__->register_method(
3782     method    => 'hold_has_copy_at',
3783     api_name  => 'open-ils.circ.hold.has_copy_at',
3784     signature => {
3785         desc   =>
3786                 'Returns the ID of the found copy and name of the shelving location if there is ' .
3787                 'an available copy at the specified org unit.  Returns empty hash otherwise.  '   .
3788                 'The anticipated use for this method is to determine whether an item is '         .
3789                 'available at the library where the user is placing the hold (or, alternatively, '.
3790                 'at the pickup library) to encourage bypassing the hold placement and just '      .
3791                 'checking out the item.' ,
3792         params => [
3793             { desc => 'Authentication Token', type => 'string' },
3794             { desc => 'Method Arguments.  Options include: hold_type, hold_target, org_unit.  '
3795                     . 'hold_type is the hold type code (T, V, C, M, ...).  '
3796                     . 'hold_target is the identifier of the hold target object.  '
3797                     . 'org_unit is org unit ID.',
3798               type => 'object'
3799             }
3800         ],
3801         return => {
3802             desc => q/Result hash like { "copy" : copy_id, "location" : location_name }, empty hash on misses, event on error./,
3803             type => 'object'
3804         }
3805     }
3806 );
3807
3808 sub hold_has_copy_at {
3809     my($self, $conn, $auth, $args) = @_;
3810
3811     my $e = new_editor(authtoken=>$auth);
3812     $e->checkauth or return $e->event;
3813
3814     my $hold_type   = $$args{hold_type};
3815     my $hold_target = $$args{hold_target};
3816     my $org_unit    = $$args{org_unit};
3817
3818     my $query = {
3819         select => {acp => ['id'], acpl => ['name']},
3820         from   => {
3821             acp => {
3822                 acpl => {field => 'id', filter => { holdable => 't'}, fkey => 'location'},
3823                 ccs  => {field => 'id', filter => { holdable => 't'}, fkey => 'status'  }
3824             }
3825         },
3826         where => {'+acp' => { circulate => 't', deleted => 'f', holdable => 't', circ_lib => $org_unit, status => [0,7]}},
3827         limit => 1
3828     };
3829
3830     if($hold_type eq 'C' or $hold_type eq 'F' or $hold_type eq 'R') {
3831
3832         $query->{where}->{'+acp'}->{id} = $hold_target;
3833
3834     } elsif($hold_type eq 'V') {
3835
3836         $query->{where}->{'+acp'}->{call_number} = $hold_target;
3837
3838     } elsif($hold_type eq 'P') {
3839
3840         $query->{from}->{acp}->{acpm} = {
3841             field  => 'target_copy',
3842             fkey   => 'id',
3843             filter => {part => $hold_target},
3844         };
3845
3846     } elsif($hold_type eq 'I') {
3847
3848         $query->{from}->{acp}->{sitem} = {
3849             field  => 'unit',
3850             fkey   => 'id',
3851             filter => {issuance => $hold_target},
3852         };
3853
3854     } elsif($hold_type eq 'T') {
3855
3856         $query->{from}->{acp}->{acn} = {
3857             field  => 'id',
3858             fkey   => 'call_number',
3859             'join' => {
3860                 bre => {
3861                     field  => 'id',
3862                     filter => {id => $hold_target},
3863                     fkey   => 'record'
3864                 }
3865             }
3866         };
3867
3868     } else {
3869
3870         $query->{from}->{acp}->{acn} = {
3871             field => 'id',
3872             fkey  => 'call_number',
3873             join  => {
3874                 bre => {
3875                     field => 'id',
3876                     fkey  => 'record',
3877                     join  => {
3878                         mmrsm => {
3879                             field  => 'source',
3880                             fkey   => 'id',
3881                             filter => {metarecord => $hold_target},
3882                         }
3883                     }
3884                 }
3885             }
3886         };
3887     }
3888
3889     my $res = $e->json_query($query)->[0] or return {};
3890     return {copy => $res->{id}, location => $res->{name}} if $res;
3891 }
3892
3893
3894 # returns true if the user already has an item checked out
3895 # that could be used to fulfill the requested hold.
3896 sub hold_item_is_checked_out {
3897     my($e, $user_id, $hold_type, $hold_target) = @_;
3898
3899     my $query = {
3900         select => {acp => ['id']},
3901         from   => {acp => {}},
3902         where  => {
3903             '+acp' => {
3904                 id => {
3905                     in => { # copies for circs the user has checked out
3906                         select => {circ => ['target_copy']},
3907                         from   => 'circ',
3908                         where  => {
3909                             usr => $user_id,
3910                             checkin_time => undef,
3911                             '-or' => [
3912                                 {stop_fines => ["MAXFINES","LONGOVERDUE"]},
3913                                 {stop_fines => undef}
3914                             ],
3915                         }
3916                     }
3917                 }
3918             }
3919         },
3920         limit => 1
3921     };
3922
3923     if($hold_type eq 'C' || $hold_type eq 'R' || $hold_type eq 'F') {
3924
3925         $query->{where}->{'+acp'}->{id}->{in}->{where}->{'target_copy'} = $hold_target;
3926
3927     } elsif($hold_type eq 'V') {
3928
3929         $query->{where}->{'+acp'}->{call_number} = $hold_target;
3930
3931      } elsif($hold_type eq 'P') {
3932
3933         $query->{from}->{acp}->{acpm} = {
3934             field  => 'target_copy',
3935             fkey   => 'id',
3936             filter => {part => $hold_target},
3937         };
3938
3939      } elsif($hold_type eq 'I') {
3940
3941         $query->{from}->{acp}->{sitem} = {
3942             field  => 'unit',
3943             fkey   => 'id',
3944             filter => {issuance => $hold_target},
3945         };
3946
3947     } elsif($hold_type eq 'T') {
3948
3949         $query->{from}->{acp}->{acn} = {
3950             field  => 'id',
3951             fkey   => 'call_number',
3952             'join' => {
3953                 bre => {
3954                     field  => 'id',
3955                     filter => {id => $hold_target},
3956                     fkey   => 'record'
3957                 }
3958             }
3959         };
3960
3961     } else {
3962
3963         $query->{from}->{acp}->{acn} = {
3964             field => 'id',
3965             fkey => 'call_number',
3966             join => {
3967                 bre => {
3968                     field => 'id',
3969                     fkey => 'record',
3970                     join => {
3971                         mmrsm => {
3972                             field => 'source',
3973                             fkey => 'id',
3974                             filter => {metarecord => $hold_target},
3975                         }
3976                     }
3977                 }
3978             }
3979         };
3980     }
3981
3982     return $e->json_query($query)->[0];
3983 }
3984
3985 __PACKAGE__->register_method(
3986     method    => 'change_hold_title',
3987     api_name  => 'open-ils.circ.hold.change_title',
3988     signature => {
3989         desc => q/
3990             Updates all title level holds targeting the specified bibs to point a new bib./,
3991         params => [
3992             { desc => 'Authentication Token', type => 'string' },
3993             { desc => 'New Target Bib Id',    type => 'number' },
3994             { desc => 'Old Target Bib Ids',   type => 'array'  },
3995         ],
3996         return => { desc => '1 on success' }
3997     }
3998 );
3999
4000 __PACKAGE__->register_method(
4001     method    => 'change_hold_title_for_specific_holds',
4002     api_name  => 'open-ils.circ.hold.change_title.specific_holds',
4003     signature => {
4004         desc => q/
4005             Updates specified holds to target new bib./,
4006         params => [
4007             { desc => 'Authentication Token', type => 'string' },
4008             { desc => 'New Target Bib Id',    type => 'number' },
4009             { desc => 'Holds Ids for holds to update',   type => 'array'  },
4010         ],
4011         return => { desc => '1 on success' }
4012     }
4013 );
4014
4015
4016 sub change_hold_title {
4017     my( $self, $client, $auth, $new_bib_id, $bib_ids ) = @_;
4018
4019     my $e = new_editor(authtoken=>$auth, xact=>1);
4020     return $e->die_event unless $e->checkauth;
4021
4022     my $holds = $e->search_action_hold_request(
4023         [
4024             {
4025                 cancel_time      => undef,
4026                 fulfillment_time => undef,
4027                 hold_type        => 'T',
4028                 target           => $bib_ids
4029             },
4030             {
4031                 flesh        => 1,
4032                 flesh_fields => { ahr => ['usr'] }
4033             }
4034         ],
4035         { substream => 1 }
4036     );
4037
4038     for my $hold (@$holds) {
4039         $e->allowed('UPDATE_HOLD', $hold->usr->home_ou) or return $e->die_event;
4040         $logger->info("Changing hold " . $hold->id . " target from " . $hold->target . " to $new_bib_id in title hold target change");
4041         $hold->target( $new_bib_id );
4042         $e->update_action_hold_request($hold) or return $e->die_event;
4043     }
4044
4045     $e->commit;
4046
4047     _reset_hold($self, $e->requestor, $_) for @$holds;
4048
4049     return 1;
4050 }
4051
4052 sub change_hold_title_for_specific_holds {
4053     my( $self, $client, $auth, $new_bib_id, $hold_ids ) = @_;
4054
4055     my $e = new_editor(authtoken=>$auth, xact=>1);
4056     return $e->die_event unless $e->checkauth;
4057
4058     my $holds = $e->search_action_hold_request(
4059         [
4060             {
4061                 cancel_time      => undef,
4062                 fulfillment_time => undef,
4063                 hold_type        => 'T',
4064                 id               => $hold_ids
4065             },
4066             {
4067                 flesh        => 1,
4068                 flesh_fields => { ahr => ['usr'] }
4069             }
4070         ],
4071         { substream => 1 }
4072     );
4073
4074     for my $hold (@$holds) {
4075         $e->allowed('UPDATE_HOLD', $hold->usr->home_ou) or return $e->die_event;
4076         $logger->info("Changing hold " . $hold->id . " target from " . $hold->target . " to $new_bib_id in title hold target change");
4077         $hold->target( $new_bib_id );
4078         $e->update_action_hold_request($hold) or return $e->die_event;
4079     }
4080
4081     $e->commit;
4082
4083     _reset_hold($self, $e->requestor, $_) for @$holds;
4084
4085     return 1;
4086 }
4087
4088 __PACKAGE__->register_method(
4089     method    => 'rec_hold_count',
4090     api_name  => 'open-ils.circ.bre.holds.count',
4091     signature => {
4092         desc => q/Returns the total number of holds that target the
4093             selected bib record or its associated copies and call_numbers/,
4094         params => [
4095             { desc => 'Bib ID', type => 'number' },
4096             { desc => q/Optional arguments.  Supported arguments include:
4097                 "pickup_lib_descendant" -> limit holds to those whose pickup
4098                 library is equal to or is a child of the provided org unit/,
4099                 type => 'object'
4100             }
4101         ],
4102         return => {desc => 'Hold count', type => 'number'}
4103     }
4104 );
4105
4106 __PACKAGE__->register_method(
4107     method    => 'rec_hold_count',
4108     api_name  => 'open-ils.circ.mmr.holds.count',
4109     signature => {
4110         desc => q/Returns the total number of holds that target the
4111             selected metarecord or its associated copies, call_numbers, and bib records/,
4112         params => [
4113             { desc => 'Metarecord ID', type => 'number' },
4114         ],
4115         return => {desc => 'Hold count', type => 'number'}
4116     }
4117 );
4118
4119 # XXX Need to add type I holds to these counts
4120 sub rec_hold_count {
4121     my($self, $conn, $target_id, $args) = @_;
4122     $args ||= {};
4123
4124     my $mmr_join = {
4125         mmrsm => {
4126             field => 'source',
4127             fkey => 'id',
4128             filter => {metarecord => $target_id}
4129         }
4130     };
4131
4132     my $bre_join = {
4133         bre => {
4134             field => 'id',
4135             filter => { id => $target_id },
4136             fkey => 'record'
4137         }
4138     };
4139
4140     if($self->api_name =~ /mmr/) {
4141         delete $bre_join->{bre}->{filter};
4142         $bre_join->{bre}->{join} = $mmr_join;
4143     }
4144
4145     my $cn_join = {
4146         acn => {
4147             field => 'id',
4148             fkey => 'call_number',
4149             join => $bre_join
4150         }
4151     };
4152
4153     my $query = {
4154         select => {ahr => [{column => 'id', transform => 'count', alias => 'count'}]},
4155         from => 'ahr',
4156         where => {
4157             '+ahr' => {
4158                 cancel_time => undef,
4159                 fulfillment_time => undef,
4160                 '-or' => [
4161                     {
4162                         '-and' => {
4163                             hold_type => [qw/C F R/],
4164                             target => {
4165                                 in => {
4166                                     select => {acp => ['id']},
4167                                     from => { acp => $cn_join }
4168                                 }
4169                             }
4170                         }
4171                     },
4172                     {
4173                         '-and' => {
4174                             hold_type => 'V',
4175                             target => {
4176                                 in => {
4177                                     select => {acn => ['id']},
4178                                     from => {acn => $bre_join}
4179                                 }
4180                             }
4181                         }
4182                     },
4183                     {
4184                         '-and' => {
4185                             hold_type => 'P',
4186                             target => {
4187                                 in => {
4188                                     select => {bmp => ['id']},
4189                                     from => {bmp => $bre_join}
4190                                 }
4191                             }
4192                         }
4193                     },
4194                     {
4195                         '-and' => {
4196                             hold_type => 'T',
4197                             target => $target_id
4198                         }
4199                     }
4200                 ]
4201             }
4202         }
4203     };
4204
4205     if($self->api_name =~ /mmr/) {
4206         $query->{where}->{'+ahr'}->{'-or'}->[3] = {
4207             '-and' => {
4208                 hold_type => 'T',
4209                 target => {
4210                     in => {
4211                         select => {bre => ['id']},
4212                         from => {bre => $mmr_join}
4213                     }
4214                 }
4215             }
4216         };
4217
4218         $query->{where}->{'+ahr'}->{'-or'}->[4] = {
4219             '-and' => {
4220                 hold_type => 'M',
4221                 target => $target_id
4222             }
4223         };
4224     }
4225
4226
4227     if (my $pld = $args->{pickup_lib_descendant}) {
4228
4229         my $top_ou = new_editor()->search_actor_org_unit(
4230             {parent_ou => undef}
4231         )->[0]; # XXX Assumes single root node. Not alone in this...
4232
4233         $query->{where}->{'+ahr'}->{pickup_lib} = {
4234             in => {
4235                 select  => {aou => [{ 
4236                     column => 'id', 
4237                     transform => 'actor.org_unit_descendants', 
4238                     result_field => 'id' 
4239                 }]},
4240                 from    => 'aou',
4241                 where   => {id => $pld}
4242             }
4243         } if ($pld != $top_ou->id);
4244     }
4245
4246
4247     return new_editor()->json_query($query)->[0]->{count};
4248 }
4249
4250 # A helper function to calculate a hold's expiration time at a given
4251 # org_unit. Takes the org_unit as an argument and returns either the
4252 # hold expire time as an ISO8601 string or undef if there is no hold
4253 # expiration interval set for the subject ou.
4254 sub calculate_expire_time
4255 {
4256     my $ou = shift;
4257     my $interval = $U->ou_ancestor_setting_value($ou, OILS_SETTING_HOLD_EXPIRE);
4258     if($interval) {
4259         my $date = DateTime->now->add(seconds => OpenSRF::Utils::interval_to_seconds($interval));
4260         return $U->epoch2ISO8601($date->epoch);
4261     }
4262     return undef;
4263 }
4264
4265
4266 __PACKAGE__->register_method(
4267     method    => 'mr_hold_filter_attrs',
4268     api_name  => 'open-ils.circ.mmr.holds.filters',
4269     authoritative => 1,
4270     stream => 1,
4271     signature => {
4272         desc => q/
4273             Returns the set of available formats and languages for the
4274             constituent records of the provided metarcord.
4275             If an array of hold IDs is also provided, information about
4276             each is returned as well.  This information includes:
4277             1. a slightly easier to read version of holdable_formats
4278             2. attributes describing the set of format icons included
4279                in the set of desired, constituent records.
4280         /,
4281         params => [
4282             {desc => 'Metarecord ID', type => 'number'},
4283             {desc => 'Context Org ID', type => 'number'},
4284             {desc => 'Hold ID List', type => 'array'},
4285         ],
4286         return => {
4287             desc => q/
4288                 Stream of objects.  The first will have a 'metarecord' key
4289                 containing non-hold-specific metarecord information, subsequent
4290                 responses will contain a 'hold' key containing hold-specific
4291                 information
4292             /, 
4293             type => 'object'
4294         }
4295     }
4296 );
4297
4298 sub mr_hold_filter_attrs { 
4299     my ($self, $client, $mr_id, $org_id, $hold_ids) = @_;
4300     my $e = new_editor();
4301
4302     # by default, return MR / hold attributes for all constituent
4303     # records with holdable copies.  If there is a hard boundary,
4304     # though, limit to records with copies within the boundary,
4305     # since anything outside the boundary can never be held.
4306     my $org_depth = 0;
4307     if ($org_id) {
4308         $org_depth = $U->ou_ancestor_setting_value(
4309             $org_id, OILS_SETTING_HOLD_HARD_BOUNDARY) || 0;
4310     }
4311
4312     # get all org-scoped records w/ holdable copies for this metarecord
4313     my ($bre_ids) = $self->method_lookup(
4314         'open-ils.circ.holds.metarecord.filtered_records')->run(
4315             $mr_id, undef, $org_id, $org_depth);
4316
4317     my $item_lang_attr = 'item_lang'; # configurable?
4318     my $format_attr = $e->retrieve_config_global_flag(
4319         'opac.metarecord.holds.format_attr')->value;
4320
4321     # helper sub for fetching ccvms for a batch of record IDs
4322     sub get_batch_ccvms {
4323         my ($e, $attr, $bre_ids) = @_;
4324         return [] unless $bre_ids and @$bre_ids;
4325         my $vals = $e->search_metabib_record_attr_flat({
4326             attr => $attr,
4327             id => $bre_ids
4328         });
4329         return [] unless @$vals;
4330         return $e->search_config_coded_value_map({
4331             ctype => $attr,
4332             code => [map {$_->value} @$vals]
4333         });
4334     }
4335
4336     my $langs = get_batch_ccvms($e, $item_lang_attr, $bre_ids);
4337     my $formats = get_batch_ccvms($e, $format_attr, $bre_ids);
4338
4339     $client->respond({
4340         metarecord => {
4341             id => $mr_id,
4342             formats => $formats,
4343             langs => $langs
4344         }
4345     });
4346
4347     return unless $hold_ids;
4348     my $icon_attr = $e->retrieve_config_global_flag('opac.icon_attr');
4349     $icon_attr = $icon_attr ? $icon_attr->value : '';
4350
4351     for my $hold_id (@$hold_ids) {
4352         my $hold = $e->retrieve_action_hold_request($hold_id) 
4353             or return $e->event;
4354
4355         next unless $hold->hold_type eq 'M';
4356
4357         my $resp = {
4358             hold => {
4359                 id => $hold_id,
4360                 formats => [],
4361                 langs => []
4362             }
4363         };
4364
4365         # collect the ccvm's for the selected formats / language
4366         # (i.e. the holdable formats) on the MR.
4367         # this assumes a two-key structure for format / language,
4368         # though no assumption is made about the keys themselves.
4369         my $hformats = OpenSRF::Utils::JSON->JSON2perl($hold->holdable_formats);
4370         my $lang_vals = [];
4371         my $format_vals = [];
4372         for my $val (values %$hformats) {
4373             # val is either a single ccvm or an array of them
4374             $val = [$val] unless ref $val eq 'ARRAY';
4375             for my $node (@$val) {
4376                 push (@$lang_vals, $node->{_val})   
4377                     if $node->{_attr} eq $item_lang_attr; 
4378                 push (@$format_vals, $node->{_val})   
4379                     if $node->{_attr} eq $format_attr;
4380             }
4381         }
4382
4383         # fetch the ccvm's for consistency with the {metarecord} blob
4384         $resp->{hold}{formats} = $e->search_config_coded_value_map({
4385             ctype => $format_attr, code => $format_vals});
4386         $resp->{hold}{langs} = $e->search_config_coded_value_map({
4387             ctype => $item_lang_attr, code => $lang_vals});
4388
4389         # find all of the bib records within this metarcord whose 
4390         # format / language match the holdable formats on the hold
4391         my ($bre_ids) = $self->method_lookup(
4392             'open-ils.circ.holds.metarecord.filtered_records')->run(
4393                 $hold->target, $hold->holdable_formats, 
4394                 $hold->selection_ou, $hold->selection_depth);
4395
4396         # now find all of the 'icon' attributes for the records
4397         $resp->{hold}{icons} = get_batch_ccvms($e, $icon_attr, $bre_ids);
4398         $client->respond($resp);
4399     }
4400
4401     return;
4402 }
4403
4404 1;