]> git.evergreen-ils.org Git - working/Evergreen.git/blob - Open-ILS/src/perlmods/lib/OpenILS/Application/Circ/Holds.pm
c46ad21c21ff1c9eef60da48a2e5641ce704bc7f
[working/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 || $U->is_true($hold->frozen)) {
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->simplereq('open-ils.hold-targeter',
384         'open-ils.hold-targeter.target', {hold => $hold->id}
385     ) 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, cancel_time => undef},
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->simplereq('open-ils.hold-targeter',
750         'open-ils.hold-targeter.target', {hold => $hold_id});
751
752     return 1;
753 }
754
755
756 __PACKAGE__->register_method(
757     method    => "cancel_hold",
758     api_name  => "open-ils.circ.hold.cancel",
759     signature => {
760         desc   => 'Cancels the specified hold.  The login session is the requestor.  If the requestor is different from the usr field ' .
761                   'on the hold, the requestor must have CANCEL_HOLDS permissions. The hold may be either the hold object or the hold id',
762         param  => [
763             {desc => 'Authentication token',  type => 'string'},
764             {desc => 'Hold ID',               type => 'number'},
765             {desc => 'Cause of Cancellation', type => 'string'},
766             {desc => 'Note',                  type => 'string'}
767         ],
768         return => {
769             desc => '1 on success, event on error'
770         }
771     }
772 );
773
774 sub cancel_hold {
775     my($self, $client, $auth, $holdid, $cause, $note) = @_;
776
777     my $e = new_editor(authtoken=>$auth, xact=>1);
778     return $e->die_event unless $e->checkauth;
779
780     my $hold = $e->retrieve_action_hold_request($holdid)
781         or return $e->die_event;
782
783     if( $e->requestor->id ne $hold->usr ) {
784         return $e->die_event unless $e->allowed('CANCEL_HOLDS');
785     }
786
787     if ($hold->cancel_time) {
788         $e->rollback;
789         return 1;
790     }
791
792     # If the hold is captured, reset the copy status
793     if( $hold->capture_time and $hold->current_copy ) {
794
795         my $copy = $e->retrieve_asset_copy($hold->current_copy)
796             or return $e->die_event;
797
798         if( $copy->status == OILS_COPY_STATUS_ON_HOLDS_SHELF ) {
799          $logger->info("canceling hold $holdid whose item is on the holds shelf");
800 #            $logger->info("setting copy to status 'reshelving' on hold cancel");
801 #            $copy->status(OILS_COPY_STATUS_RESHELVING);
802 #            $copy->editor($e->requestor->id);
803 #            $copy->edit_date('now');
804 #            $e->update_asset_copy($copy) or return $e->event;
805
806         } elsif( $copy->status == OILS_COPY_STATUS_IN_TRANSIT ) {
807
808             my $hid = $hold->id;
809             $logger->warn("! canceling hold [$hid] that is in transit");
810             my $transid = $e->search_action_hold_transit_copy({hold=>$hold->id,cancel_time=>undef},{idlist=>1})->[0];
811
812             if( $transid ) {
813                 my $trans = $e->retrieve_action_transit_copy($transid);
814                 # Leave the transit alive, but  set the copy status to
815                 # reshelving so it will be properly reshelved when it gets back home
816                 if( $trans ) {
817                     $trans->copy_status( OILS_COPY_STATUS_RESHELVING );
818                     $e->update_action_transit_copy($trans) or return $e->die_event;
819                 }
820             }
821         }
822     }
823
824     $hold->cancel_time('now');
825     $hold->cancel_cause($cause);
826     $hold->cancel_note($note);
827     $e->update_action_hold_request($hold)
828         or return $e->die_event;
829
830     $e->commit;
831
832     # re-fetch the hold to pick up the real cancel_time (not "now") for A/T
833     $e->xact_begin;
834     $hold = $e->retrieve_action_hold_request($hold->id) or return $e->die_event;
835     $e->rollback;
836
837     if ($e->requestor->id == $hold->usr) {
838         $U->create_events_for_hook('hold_request.cancel.patron', $hold, $hold->pickup_lib);
839     } else {
840         $U->create_events_for_hook('hold_request.cancel.staff', $hold, $hold->pickup_lib);
841     }
842
843     return 1;
844 }
845
846 my $update_hold_desc = 'The login session is the requestor. '       .
847    'If the requestor is different from the usr field on the hold, ' .
848    'the requestor must have UPDATE_HOLDS permissions. '             .
849    'If supplying a hash of hold data, "id" must be included. '      .
850    'The hash is ignored if a hold object is supplied, '             .
851    'so you should supply only one kind of hold data argument.'      ;
852
853 __PACKAGE__->register_method(
854     method    => "update_hold",
855     api_name  => "open-ils.circ.hold.update",
856     signature => {
857         desc   => "Updates the specified hold.  $update_hold_desc",
858         params => [
859             {desc => 'Authentication token',         type => 'string'},
860             {desc => 'Hold Object',                  type => 'object'},
861             {desc => 'Hash of values to be applied', type => 'object'}
862         ],
863         return => {
864             desc => 'Hold ID on success, event on error',
865             # type => 'number'
866         }
867     }
868 );
869
870 __PACKAGE__->register_method(
871     method    => "batch_update_hold",
872     api_name  => "open-ils.circ.hold.update.batch",
873     stream    => 1,
874     signature => {
875         desc   => "Updates the specified hold(s).  $update_hold_desc",
876         params => [
877             {desc => 'Authentication token',                    type => 'string'},
878             {desc => 'Array of hold obejcts',                   type => 'array' },
879             {desc => 'Array of hashes of values to be applied', type => 'array' }
880         ],
881         return => {
882             desc => 'Hold ID per success, event per error',
883         }
884     }
885 );
886
887 sub update_hold {
888     my($self, $client, $auth, $hold, $values) = @_;
889     my $e = new_editor(authtoken=>$auth, xact=>1);
890     return $e->die_event unless $e->checkauth;
891     my $resp = update_hold_impl($self, $e, $hold, $values);
892     if ($U->event_code($resp)) {
893         $e->rollback;
894         return $resp;
895     }
896     $e->commit;     # FIXME: update_hold_impl already does $e->commit  ??
897     return $resp;
898 }
899
900 sub batch_update_hold {
901     my($self, $client, $auth, $hold_list, $values_list) = @_;
902     my $e = new_editor(authtoken=>$auth);
903     return $e->die_event unless $e->checkauth;
904
905     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.
906     $hold_list   ||= [];
907     $values_list ||= [];      # FIXME: either move this above $count declaration, or send an event if both lists undef.  Probably the latter.
908
909 # FIXME: Failing over to [] guarantees warnings for "Use of unitialized value" in update_hold_impl call.
910 # FIXME: We should be sure we only call update_hold_impl with hold object OR hash, not both.
911
912     for my $idx (0..$count-1) {
913         $e->xact_begin;
914         my $resp = update_hold_impl($self, $e, $hold_list->[$idx], $values_list->[$idx]);
915         $e->xact_commit unless $U->event_code($resp);
916         $client->respond($resp);
917     }
918
919     $e->disconnect;
920     return undef;       # not in the register return type, assuming we should always have at least one list populated
921 }
922
923 sub update_hold_impl {
924     my($self, $e, $hold, $values) = @_;
925     my $hold_status;
926     my $need_retarget = 0;
927
928     unless($hold) {
929         $hold = $e->retrieve_action_hold_request($values->{id})
930             or return $e->die_event;
931         for my $k (keys %$values) {
932             # Outside of pickup_lib (covered by the first regex) I don't know when these would currently change.
933             # But hey, why not cover things that may happen later?
934             if ($k =~ '_(lib|ou)$' || $k eq 'target' || $k eq 'hold_type' || $k eq 'requestor' || $k eq 'selection_depth' || $k eq 'holdable_formats') {
935                 if (defined $values->{$k} && defined $hold->$k() && $values->{$k} ne $hold->$k()) {
936                     # Value changed? RETARGET!
937                     $need_retarget = 1;
938                 } elsif (defined $hold->$k() != defined $values->{$k}) {
939                     # Value being set or cleared? RETARGET!
940                     $need_retarget = 1;
941                 }
942             }
943             if (defined $values->{$k}) {
944                 $hold->$k($values->{$k});
945             } else {
946                 my $f = "clear_$k"; $hold->$f();
947             }
948         }
949     }
950
951     my $orig_hold = $e->retrieve_action_hold_request($hold->id)
952         or return $e->die_event;
953
954     # don't allow the user to be changed
955     return OpenILS::Event->new('BAD_PARAMS') if $hold->usr != $orig_hold->usr;
956
957     if($hold->usr ne $e->requestor->id) {
958         # if the hold is for a different user, make sure the
959         # requestor has the appropriate permissions
960         my $usr = $e->retrieve_actor_user($hold->usr)
961             or return $e->die_event;
962         return $e->die_event unless $e->allowed('UPDATE_HOLD', $usr->home_ou);
963     }
964
965
966     # --------------------------------------------------------------
967     # Changing the request time is like playing God
968     # --------------------------------------------------------------
969     if($hold->request_time ne $orig_hold->request_time) {
970         return OpenILS::Event->new('BAD_PARAMS') if $hold->fulfillment_time;
971         return $e->die_event unless $e->allowed('UPDATE_HOLD_REQUEST_TIME', $hold->pickup_lib);
972     }
973
974
975     # --------------------------------------------------------------
976     # Code for making sure staff have appropriate permissons for cut_in_line
977     # This, as is, doesn't prevent a user from cutting their own holds in line
978     # but needs to
979     # --------------------------------------------------------------
980     if($U->is_true($hold->cut_in_line) ne $U->is_true($orig_hold->cut_in_line)) {
981         return $e->die_event unless $e->allowed('UPDATE_HOLD_REQUEST_TIME', $hold->pickup_lib);
982     }
983
984
985     # --------------------------------------------------------------
986     # Disallow hold suspencion if the hold is already captured.
987     # --------------------------------------------------------------
988     if ($U->is_true($hold->frozen) and not $U->is_true($orig_hold->frozen)) {
989         $hold_status = _hold_status($e, $hold);
990         if ($hold_status > 2 && $hold_status != 7) { # hold is captured
991             $logger->info("bypassing hold freeze on captured hold");
992             return OpenILS::Event->new('HOLD_SUSPEND_AFTER_CAPTURE');
993         }
994     }
995
996
997     # --------------------------------------------------------------
998     # if the hold is on the holds shelf or in transit and the pickup
999     # lib changes we need to create a new transit.
1000     # --------------------------------------------------------------
1001     if($orig_hold->pickup_lib ne $hold->pickup_lib) {
1002
1003         $hold_status = _hold_status($e, $hold) unless $hold_status;
1004
1005         if($hold_status == 3) { # in transit
1006
1007             return $e->die_event unless $e->allowed('UPDATE_PICKUP_LIB_FROM_TRANSIT', $orig_hold->pickup_lib);
1008             return $e->die_event unless $e->allowed('UPDATE_PICKUP_LIB_FROM_TRANSIT', $hold->pickup_lib);
1009
1010             $logger->info("updating pickup lib for hold ".$hold->id." while already in transit");
1011
1012             # update the transit to reflect the new pickup location
1013             my $transit = $e->search_action_hold_transit_copy(
1014                 {hold=>$hold->id, cancel_time => undef, dest_recv_time => undef})->[0]
1015                 or return $e->die_event;
1016
1017             $transit->prev_dest($transit->dest); # mark the previous destination on the transit
1018             $transit->dest($hold->pickup_lib);
1019             $e->update_action_hold_transit_copy($transit) or return $e->die_event;
1020
1021         } elsif($hold_status == 4 or $hold_status == 5 or $hold_status == 8) { # on holds shelf
1022
1023             return $e->die_event unless $e->allowed('UPDATE_PICKUP_LIB_FROM_HOLDS_SHELF', $orig_hold->pickup_lib);
1024             return $e->die_event unless $e->allowed('UPDATE_PICKUP_LIB_FROM_HOLDS_SHELF', $hold->pickup_lib);
1025
1026             $logger->info("updating pickup lib for hold ".$hold->id." while on holds shelf");
1027
1028             if ($hold->pickup_lib eq $orig_hold->current_shelf_lib) {
1029                 # This can happen if the pickup lib is changed while the hold is
1030                 # on the shelf, then changed back to the original pickup lib.
1031                 # Restore the original shelf_expire_time to prevent abuse.
1032                 set_hold_shelf_expire_time(undef, $hold, $e, $hold->shelf_time);
1033
1034             } else {
1035                 # clear to prevent premature shelf expiration
1036                 $hold->clear_shelf_expire_time;
1037             }
1038         }
1039     }
1040
1041     if($U->is_true($hold->frozen)) {
1042         $logger->info("clearing current_copy and check_time for frozen hold ".$hold->id);
1043         $hold->clear_current_copy;
1044         $hold->clear_prev_check_time;
1045         # Clear expire_time to prevent frozen holds from expiring.
1046         $logger->info("clearing expire_time for frozen hold ".$hold->id);
1047         $hold->clear_expire_time;
1048     }
1049
1050     # If the hold_expire_time is in the past && is not equal to the
1051     # original expire_time, then reset the expire time to be in the
1052     # future.
1053     if ($hold->expire_time && $U->datecmp($hold->expire_time) == -1 && $U->datecmp($hold->expire_time, $orig_hold->expire_time) != 0) {
1054         $hold->expire_time(calculate_expire_time($hold->request_lib));
1055     }
1056
1057     # If the hold is reactivated, reset the expire_time.
1058     if(!$U->is_true($hold->frozen) && $U->is_true($orig_hold->frozen)) {
1059         $logger->info("Reset expire_time on activated hold ".$hold->id);
1060         $hold->expire_time(calculate_expire_time($hold->request_lib));
1061     }
1062
1063     $e->update_action_hold_request($hold) or return $e->die_event;
1064     $e->commit;
1065
1066     if(!$U->is_true($hold->frozen) && $U->is_true($orig_hold->frozen)) {
1067         $logger->info("Running targeter on activated hold ".$hold->id);
1068         $U->simplereq('open-ils.hold-targeter', 
1069             'open-ils.hold-targeter.target', {hold => $hold->id});
1070     }
1071
1072     # a change to mint-condition changes the set of potential copies, so retarget the hold;
1073     if($U->is_true($hold->mint_condition) and !$U->is_true($orig_hold->mint_condition)) {
1074         _reset_hold($self, $e->requestor, $hold)
1075     } elsif($need_retarget && !defined $hold->capture_time()) { # If needed, retarget the hold due to changes
1076         $U->simplereq('open-ils.hold-targeter', 
1077             'open-ils.hold-targeter.target', {hold => $hold->id});
1078     }
1079
1080     return $hold->id;
1081 }
1082
1083 # this does not update the hold in the DB.  It only
1084 # sets the shelf_expire_time field on the hold object.
1085 # start_time is optional and defaults to 'now'
1086 sub set_hold_shelf_expire_time {
1087     my ($class, $hold, $editor, $start_time) = @_;
1088
1089     my $shelf_expire = $U->ou_ancestor_setting_value(
1090         $hold->pickup_lib,
1091         'circ.holds.default_shelf_expire_interval',
1092         $editor
1093     );
1094
1095     return undef unless $shelf_expire;
1096
1097     $start_time = ($start_time) ?
1098         DateTime::Format::ISO8601->new->parse_datetime(cleanse_ISO8601($start_time)) :
1099         DateTime->now(time_zone => 'local'); # without time_zone we get UTC ... yuck!
1100
1101     my $seconds = OpenSRF::Utils->interval_to_seconds($shelf_expire);
1102     my $expire_time = $start_time->add(seconds => $seconds);
1103
1104     # if the shelf expire time overlaps with a pickup lib's
1105     # closed date, push it out to the first open date
1106     my $dateinfo = $U->storagereq(
1107         'open-ils.storage.actor.org_unit.closed_date.overlap',
1108         $hold->pickup_lib, $expire_time->strftime('%FT%T%z'));
1109
1110     if($dateinfo) {
1111         my $dt_parser = DateTime::Format::ISO8601->new;
1112         $expire_time = $dt_parser->parse_datetime(cleanse_ISO8601($dateinfo->{end}));
1113
1114         # TODO: enable/disable time bump via setting?
1115         $expire_time->set(hour => '23', minute => '59', second => '59');
1116
1117         $logger->info("circulator: shelf_expire_time overlaps".
1118             " with closed date, pushing expire time to $expire_time");
1119     }
1120
1121     $hold->shelf_expire_time($expire_time->strftime('%FT%T%z'));
1122     return undef;
1123 }
1124
1125
1126 sub transit_hold {
1127     my($e, $orig_hold, $hold, $copy) = @_;
1128     my $src  = $orig_hold->pickup_lib;
1129     my $dest = $hold->pickup_lib;
1130
1131     $logger->info("putting hold into transit on pickup_lib update");
1132
1133     my $transit = Fieldmapper::action::hold_transit_copy->new;
1134     $transit->hold($hold->id);
1135     $transit->source($src);
1136     $transit->dest($dest);
1137     $transit->target_copy($copy->id);
1138     $transit->source_send_time('now');
1139     $transit->copy_status(OILS_COPY_STATUS_ON_HOLDS_SHELF);
1140
1141     $copy->status(OILS_COPY_STATUS_IN_TRANSIT);
1142     $copy->editor($e->requestor->id);
1143     $copy->edit_date('now');
1144
1145     $e->create_action_hold_transit_copy($transit) or return $e->die_event;
1146     $e->update_asset_copy($copy) or return $e->die_event;
1147     return undef;
1148 }
1149
1150 # if the hold is frozen, this method ensures that the hold is not "targeted",
1151 # that is, it clears the current_copy and prev_check_time to essentiallly
1152 # reset the hold.  If it is being activated, it runs the targeter in the background
1153 sub update_hold_if_frozen {
1154     my($self, $e, $hold, $orig_hold) = @_;
1155     return if $hold->capture_time;
1156
1157     if($U->is_true($hold->frozen)) {
1158         $logger->info("clearing current_copy and check_time for frozen hold ".$hold->id);
1159         $hold->clear_current_copy;
1160         $hold->clear_prev_check_time;
1161
1162     } else {
1163         if($U->is_true($orig_hold->frozen)) {
1164             $logger->info("Running targeter on activated hold ".$hold->id);
1165             $U->simplereq('open-ils.hold-targeter', 
1166                 'open-ils.hold-targeter.target', {hold => $hold->id});
1167         }
1168     }
1169 }
1170
1171 __PACKAGE__->register_method(
1172     method    => "hold_note_CUD",
1173     api_name  => "open-ils.circ.hold_request.note.cud",
1174     signature => {
1175         desc   => 'Create, update or delete a hold request note.  If the operator (from Auth. token) '
1176                 . 'is not the owner of the hold, the UPDATE_HOLD permission is required',
1177         params => [
1178             { desc => 'Authentication token', type => 'string' },
1179             { desc => 'Hold note object',     type => 'object' }
1180         ],
1181         return => {
1182             desc => 'Returns the note ID, event on error'
1183         },
1184     }
1185 );
1186
1187 sub hold_note_CUD {
1188     my($self, $conn, $auth, $note) = @_;
1189
1190     my $e = new_editor(authtoken => $auth, xact => 1);
1191     return $e->die_event unless $e->checkauth;
1192
1193     my $hold = $e->retrieve_action_hold_request($note->hold)
1194         or return $e->die_event;
1195
1196     if($hold->usr ne $e->requestor->id) {
1197         my $usr = $e->retrieve_actor_user($hold->usr);
1198         return $e->die_event unless $e->allowed('UPDATE_HOLD', $usr->home_ou);
1199         $note->staff('t') if $note->isnew;
1200     }
1201
1202     if($note->isnew) {
1203         $e->create_action_hold_request_note($note) or return $e->die_event;
1204     } elsif($note->ischanged) {
1205         $e->update_action_hold_request_note($note) or return $e->die_event;
1206     } elsif($note->isdeleted) {
1207         $e->delete_action_hold_request_note($note) or return $e->die_event;
1208     }
1209
1210     $e->commit;
1211     return $note->id;
1212 }
1213
1214
1215 __PACKAGE__->register_method(
1216     method    => "retrieve_hold_status",
1217     api_name  => "open-ils.circ.hold.status.retrieve",
1218     signature => {
1219         desc   => 'Calculates the current status of the hold. The requestor must have '      .
1220                   'VIEW_HOLD permissions if the hold is for a user other than the requestor' ,
1221         param  => [
1222             { desc => 'Hold ID', type => 'number' }
1223         ],
1224         return => {
1225             # type => 'number',     # event sometimes
1226             desc => <<'END_OF_DESC'
1227 Returns event on error or:
1228 -1 on error (for now),
1229  1 for 'waiting for copy to become available',
1230  2 for 'waiting for copy capture',
1231  3 for 'in transit',
1232  4 for 'arrived',
1233  5 for 'hold-shelf-delay'
1234  6 for 'canceled'
1235  7 for 'suspended'
1236  8 for 'captured, on wrong hold shelf'
1237  9 for 'fulfilled'
1238 END_OF_DESC
1239         }
1240     }
1241 );
1242
1243 sub retrieve_hold_status {
1244     my($self, $client, $auth, $hold_id) = @_;
1245
1246     my $e = new_editor(authtoken => $auth);
1247     return $e->event unless $e->checkauth;
1248     my $hold = $e->retrieve_action_hold_request($hold_id)
1249         or return $e->event;
1250
1251     if( $e->requestor->id != $hold->usr ) {
1252         return $e->event unless $e->allowed('VIEW_HOLD');
1253     }
1254
1255     return _hold_status($e, $hold);
1256
1257 }
1258
1259 sub _hold_status {
1260     my($e, $hold) = @_;
1261     if ($hold->cancel_time) {
1262         return 6;
1263     }
1264     if ($U->is_true($hold->frozen) && !$hold->capture_time) {
1265         return 7;
1266     }
1267     if ($hold->current_shelf_lib and $hold->current_shelf_lib ne $hold->pickup_lib) {
1268         return 8;
1269     }
1270     if ($hold->fulfillment_time) {
1271         return 9;
1272     }
1273     return 1 unless $hold->current_copy;
1274     return 2 unless $hold->capture_time;
1275
1276     my $copy = $hold->current_copy;
1277     unless( ref $copy ) {
1278         $copy = $e->retrieve_asset_copy($hold->current_copy)
1279             or return $e->event;
1280     }
1281
1282     return 3 if $copy->status == OILS_COPY_STATUS_IN_TRANSIT;
1283
1284     if($copy->status == OILS_COPY_STATUS_ON_HOLDS_SHELF) {
1285
1286         my $hs_wait_interval = $U->ou_ancestor_setting_value($hold->pickup_lib, 'circ.hold_shelf_status_delay');
1287         return 4 unless $hs_wait_interval;
1288
1289         # if a hold_shelf_status_delay interval is defined and start_time plus
1290         # the interval is greater than now, consider the hold to be in the virtual
1291         # "on its way to the holds shelf" status. Return 5.
1292
1293         my $transit    = $e->search_action_hold_transit_copy({
1294                             hold           => $hold->id,
1295                             target_copy    => $copy->id,
1296                             cancel_time     => undef,
1297                             dest_recv_time => {'!=' => undef},
1298                          })->[0];
1299         my $start_time = ($transit) ? $transit->dest_recv_time : $hold->capture_time;
1300         $start_time    = DateTime::Format::ISO8601->new->parse_datetime(cleanse_ISO8601($start_time));
1301         my $end_time   = $start_time->add(seconds => OpenSRF::Utils::interval_to_seconds($hs_wait_interval));
1302
1303         return 5 if $end_time > DateTime->now;
1304         return 4;
1305     }
1306
1307     return -1;  # error
1308 }
1309
1310
1311
1312 __PACKAGE__->register_method(
1313     method    => "retrieve_hold_queue_stats",
1314     api_name  => "open-ils.circ.hold.queue_stats.retrieve",
1315     signature => {
1316         desc   => 'Returns summary data about the state of a hold',
1317         params => [
1318             { desc => 'Authentication token',  type => 'string'},
1319             { desc => 'Hold ID', type => 'number'},
1320         ],
1321         return => {
1322             desc => q/Summary object with keys:
1323                 total_holds : total holds in queue
1324                 queue_position : current queue position
1325                 potential_copies : number of potential copies for this hold
1326                 estimated_wait : estimated wait time in days
1327                 status : hold status
1328                      -1 => error or unexpected state,
1329                      1 => 'waiting for copy to become available',
1330                      2 => 'waiting for copy capture',
1331                      3 => 'in transit',
1332                      4 => 'arrived',
1333                      5 => 'hold-shelf-delay'
1334             /,
1335             type => 'object'
1336         }
1337     }
1338 );
1339
1340 sub retrieve_hold_queue_stats {
1341     my($self, $conn, $auth, $hold_id) = @_;
1342     my $e = new_editor(authtoken => $auth);
1343     return $e->event unless $e->checkauth;
1344     my $hold = $e->retrieve_action_hold_request($hold_id) or return $e->event;
1345     if($e->requestor->id != $hold->usr) {
1346         return $e->event unless $e->allowed('VIEW_HOLD');
1347     }
1348     return retrieve_hold_queue_status_impl($e, $hold);
1349 }
1350
1351 sub retrieve_hold_queue_status_impl {
1352     my $e = shift;
1353     my $hold = shift;
1354
1355     # The holds queue is defined as the distinct set of holds that share at
1356     # least one potential copy with the context hold, plus any holds that
1357     # share the same hold type and target.  The latter part exists to
1358     # accomodate holds that currently have no potential copies
1359     my $q_holds = $e->json_query({
1360
1361         # fetch cut_in_line and request_time since they're in the order_by
1362         # and we're asking for distinct values
1363         select => {ahr => ['id', 'cut_in_line', 'request_time']},
1364         from   => 'ahr',
1365         where => {
1366             id => { in => {
1367                 select => { ahcm => ['hold'] },
1368                 from   => {
1369                     'ahcm' => {
1370                         'ahcm2' => {
1371                             'class' => 'ahcm',
1372                             'field' => 'target_copy',
1373                             'fkey'  => 'target_copy'
1374                         }
1375                     }
1376                 },
1377                 where => { '+ahcm2' => { hold => $hold->id } },
1378                 distinct => 1
1379             }}
1380         },
1381         order_by => [
1382             {
1383                 "class" => "ahr",
1384                 "field" => "cut_in_line",
1385                 "transform" => "coalesce",
1386                 "params" => [ 0 ],
1387                 "direction" => "desc"
1388             },
1389             { "class" => "ahr", "field" => "request_time" }
1390         ],
1391         distinct => 1
1392     });
1393
1394     if (!@$q_holds) { # none? maybe we don't have a map ...
1395         $q_holds = $e->json_query({
1396             select => {ahr => ['id', 'cut_in_line', 'request_time']},
1397             from   => 'ahr',
1398             order_by => [
1399                 {
1400                     "class" => "ahr",
1401                     "field" => "cut_in_line",
1402                     "transform" => "coalesce",
1403                     "params" => [ 0 ],
1404                     "direction" => "desc"
1405                 },
1406                 { "class" => "ahr", "field" => "request_time" }
1407             ],
1408             where    => {
1409                 hold_type => $hold->hold_type,
1410                 target    => $hold->target,
1411                 capture_time => undef,
1412                 cancel_time => undef,
1413                 '-or' => [
1414                     {expire_time => undef },
1415                     {expire_time => {'>' => 'now'}}
1416                 ]
1417            }
1418         });
1419     }
1420
1421
1422     my $qpos = 1;
1423     for my $h (@$q_holds) {
1424         last if $h->{id} == $hold->id;
1425         $qpos++;
1426     }
1427
1428     my $hold_data = $e->json_query({
1429         select => {
1430             acp => [ {column => 'id', transform => 'count', aggregate => 1, alias => 'count'} ],
1431             ccm => [ {column =>'avg_wait_time'} ]
1432         },
1433         from => {
1434             ahcm => {
1435                 acp => {
1436                     join => {
1437                         ccm => {type => 'left'}
1438                     }
1439                 }
1440             }
1441         },
1442         where => {'+ahcm' => {hold => $hold->id} }
1443     });
1444
1445     my $user_org = $e->json_query({select => {au => ['home_ou']}, from => 'au', where => {id => $hold->usr}})->[0]->{home_ou};
1446
1447     my $default_wait = $U->ou_ancestor_setting_value(
1448         $user_org, OILS_SETTING_HOLD_ESIMATE_WAIT_INTERVAL, $e);
1449     my $min_wait = $U->ou_ancestor_setting_value(
1450         $user_org, 'circ.holds.min_estimated_wait_interval', $e);
1451     $min_wait = OpenSRF::Utils::interval_to_seconds($min_wait || '0 seconds');
1452     $default_wait ||= '0 seconds';
1453
1454     # Estimated wait time is the average wait time across the set
1455     # of potential copies, divided by the number of potential copies
1456     # times the queue position.
1457
1458     my $combined_secs = 0;
1459     my $num_potentials = 0;
1460
1461     for my $wait_data (@$hold_data) {
1462         my $count += $wait_data->{count};
1463         $combined_secs += $count *
1464             OpenSRF::Utils::interval_to_seconds($wait_data->{avg_wait_time} || $default_wait);
1465         $num_potentials += $count;
1466     }
1467
1468     my $estimated_wait = -1;
1469
1470     if($num_potentials) {
1471         my $avg_wait = $combined_secs / $num_potentials;
1472         $estimated_wait = $qpos * ($avg_wait / $num_potentials);
1473         $estimated_wait = $min_wait if $estimated_wait < $min_wait and $estimated_wait != -1;
1474     }
1475
1476     return {
1477         total_holds      => scalar(@$q_holds),
1478         queue_position   => $qpos,
1479         potential_copies => $num_potentials,
1480         status           => _hold_status( $e, $hold ),
1481         estimated_wait   => int($estimated_wait)
1482     };
1483 }
1484
1485
1486 sub fetch_open_hold_by_current_copy {
1487     my $class = shift;
1488     my $copyid = shift;
1489     my $hold = $apputils->simplereq(
1490         'open-ils.cstore',
1491         'open-ils.cstore.direct.action.hold_request.search.atomic',
1492         { current_copy =>  $copyid , cancel_time => undef, fulfillment_time => undef });
1493     return $hold->[0] if ref($hold);
1494     return undef;
1495 }
1496
1497 sub fetch_related_holds {
1498     my $class = shift;
1499     my $copyid = shift;
1500     return $apputils->simplereq(
1501         'open-ils.cstore',
1502         'open-ils.cstore.direct.action.hold_request.search.atomic',
1503         { current_copy =>  $copyid , cancel_time => undef, fulfillment_time => undef });
1504 }
1505
1506
1507 __PACKAGE__->register_method(
1508     method    => "hold_pull_list",
1509     api_name  => "open-ils.circ.hold_pull_list.retrieve",
1510     signature => {
1511         desc   => 'Returns (reference to) a list of holds that need to be "pulled" by a given location. ' .
1512                   'The location is determined by the login session.',
1513         params => [
1514             { desc => 'Limit (optional)',  type => 'number'},
1515             { desc => 'Offset (optional)', type => 'number'},
1516         ],
1517         return => {
1518             desc => 'reference to a list of holds, or event on failure',
1519         }
1520     }
1521 );
1522
1523 __PACKAGE__->register_method(
1524     method    => "hold_pull_list",
1525     api_name  => "open-ils.circ.hold_pull_list.id_list.retrieve",
1526     signature => {
1527         desc   => 'Returns (reference to) a list of holds IDs that need to be "pulled" by a given location. ' .
1528                   'The location is determined by the login session.',
1529         params => [
1530             { desc => 'Limit (optional)',  type => 'number'},
1531             { desc => 'Offset (optional)', type => 'number'},
1532         ],
1533         return => {
1534             desc => 'reference to a list of holds, or event on failure',
1535         }
1536     }
1537 );
1538
1539 __PACKAGE__->register_method(
1540     method    => "hold_pull_list",
1541     api_name  => "open-ils.circ.hold_pull_list.retrieve.count",
1542     signature => {
1543         desc   => 'Returns a count of holds that need to be "pulled" by a given location. ' .
1544                   'The location is determined by the login session.',
1545         params => [
1546             { desc => 'Limit (optional)',  type => 'number'},
1547             { desc => 'Offset (optional)', type => 'number'},
1548         ],
1549         return => {
1550             desc => 'Holds count (integer), or event on failure',
1551             # type => 'number'
1552         }
1553     }
1554 );
1555
1556 __PACKAGE__->register_method(
1557     method    => "hold_pull_list",
1558     stream => 1,
1559     # TODO: tag with api_level 2 once fully supported
1560     api_name  => "open-ils.circ.hold_pull_list.fleshed.stream",
1561     signature => {
1562         desc   => q/Returns a stream of fleshed holds  that need to be 
1563                     "pulled" by a given location.  The location is 
1564                     determined by the login session.  
1565                     This API calls always run in authoritative mode./,
1566         params => [
1567             { desc => 'Limit (optional)',  type => 'number'},
1568             { desc => 'Offset (optional)', type => 'number'},
1569         ],
1570         return => {
1571             desc => 'Stream of holds holds, or event on failure',
1572         }
1573     }
1574 );
1575
1576 sub hold_pull_list {
1577     my( $self, $conn, $authtoken, $limit, $offset ) = @_;
1578     my( $reqr, $evt ) = $U->checkses($authtoken);
1579     return $evt if $evt;
1580
1581     my $org = $reqr->ws_ou || $reqr->home_ou;
1582     # the perm locaiton shouldn't really matter here since holds
1583     # will exist all over and VIEW_HOLDS should be universal
1584     $evt = $U->check_perms($reqr->id, $org, 'VIEW_HOLD');
1585     return $evt if $evt;
1586
1587     if($self->api_name =~ /count/) {
1588
1589         my $count = $U->storagereq(
1590             'open-ils.storage.direct.action.hold_request.pull_list.current_copy_circ_lib.status_filtered.count',
1591             $org, $limit, $offset );
1592
1593         $logger->info("Grabbing pull list for org unit $org with $count items");
1594         return $count;
1595
1596     } elsif( $self->api_name =~ /id_list/ ) {
1597         $U->storagereq(
1598             'open-ils.storage.direct.action.hold_request.pull_list.id_list.current_copy_circ_lib.status_filtered.atomic',
1599             $org, $limit, $offset );
1600
1601     } elsif ($self->api_name =~ /fleshed/) {
1602
1603         my $ids = $U->storagereq(
1604             'open-ils.storage.direct.action.hold_request.pull_list.id_list.current_copy_circ_lib.status_filtered.atomic',
1605             $org, $limit, $offset );
1606
1607         my $e = new_editor(xact => 1, requestor => $reqr);
1608         $conn->respond(uber_hold_impl($e, $_, {flesh_acpl => 1})) for @$ids;
1609         $e->rollback;
1610         $conn->respond_complete;
1611         return;
1612
1613     } else {
1614         $U->storagereq(
1615             'open-ils.storage.direct.action.hold_request.pull_list.search.current_copy_circ_lib.status_filtered.atomic',
1616             $org, $limit, $offset );
1617     }
1618 }
1619
1620 __PACKAGE__->register_method(
1621     method    => "print_hold_pull_list",
1622     api_name  => "open-ils.circ.hold_pull_list.print",
1623     signature => {
1624         desc   => 'Returns an HTML-formatted holds pull list',
1625         params => [
1626             { desc => 'Authtoken', type => 'string'},
1627             { desc => 'Org unit ID.  Optional, defaults to workstation org unit', type => 'number'},
1628         ],
1629         return => {
1630             desc => 'HTML string',
1631             type => 'string'
1632         }
1633     }
1634 );
1635
1636 sub print_hold_pull_list {
1637     my($self, $client, $auth, $org_id) = @_;
1638
1639     my $e = new_editor(authtoken=>$auth);
1640     return $e->event unless $e->checkauth;
1641
1642     $org_id = (defined $org_id) ? $org_id : $e->requestor->ws_ou;
1643     return $e->event unless $e->allowed('VIEW_HOLD', $org_id);
1644
1645     my $hold_ids = $U->storagereq(
1646         'open-ils.storage.direct.action.hold_request.pull_list.id_list.current_copy_circ_lib.status_filtered.atomic',
1647         $org_id, 10000);
1648
1649     return undef unless @$hold_ids;
1650
1651     $client->status(new OpenSRF::DomainObject::oilsContinueStatus);
1652
1653     # Holds will /NOT/ be in order after this ...
1654     my $holds = $e->search_action_hold_request({id => $hold_ids}, {substream => 1});
1655     $client->status(new OpenSRF::DomainObject::oilsContinueStatus);
1656
1657     # ... so we must resort.
1658     my $hold_map = +{map { $_->id => $_ } @$holds};
1659     my $sorted_holds = [];
1660     push @$sorted_holds, $hold_map->{$_} foreach @$hold_ids;
1661
1662     return $U->fire_object_event(
1663         undef, "ahr.format.pull_list", $sorted_holds,
1664         $org_id, undef, undef, $client
1665     );
1666
1667 }
1668
1669 __PACKAGE__->register_method(
1670     method    => "print_hold_pull_list_stream",
1671     stream   => 1,
1672     api_name  => "open-ils.circ.hold_pull_list.print.stream",
1673     signature => {
1674         desc   => 'Returns a stream of fleshed holds',
1675         params => [
1676             { desc => 'Authtoken', type => 'string'},
1677             { 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)',
1678               type => 'object'
1679             },
1680         ],
1681         return => {
1682             desc => 'A stream of fleshed holds',
1683             type => 'object'
1684         }
1685     }
1686 );
1687
1688 sub print_hold_pull_list_stream {
1689     my($self, $client, $auth, $params) = @_;
1690
1691     my $e = new_editor(authtoken=>$auth);
1692     return $e->die_event unless $e->checkauth;
1693
1694     delete($$params{org_id}) unless (int($$params{org_id}));
1695     delete($$params{limit}) unless (int($$params{limit}));
1696     delete($$params{offset}) unless (int($$params{offset}));
1697     delete($$params{chunk_size}) unless (int($$params{chunk_size}));
1698     delete($$params{chunk_size}) if  ($$params{chunk_size} && $$params{chunk_size} > 50); # keep the size reasonable
1699     $$params{chunk_size} ||= 10;
1700     $client->max_chunk_size($$params{chunk_size}) if (!$client->can('max_bundle_size') && $client->can('max_chunk_size'));
1701
1702     $$params{org_id} = (defined $$params{org_id}) ? $$params{org_id}: $e->requestor->ws_ou;
1703     return $e->die_event unless $e->allowed('VIEW_HOLD', $$params{org_id });
1704
1705     my $sort = [];
1706     if ($$params{sort} && @{ $$params{sort} }) {
1707         for my $s (@{ $$params{sort} }) {
1708             if ($s eq 'acplo.position') {
1709                 push @$sort, {
1710                     "class" => "acplo", "field" => "position",
1711                     "transform" => "coalesce", "params" => [999]
1712                 };
1713             } elsif ($s eq 'prefix') {
1714                 push @$sort, {"class" => "acnp", "field" => "label_sortkey"};
1715             } elsif ($s eq 'call_number') {
1716                 push @$sort, {"class" => "acn", "field" => "label_sortkey"};
1717             } elsif ($s eq 'suffix') {
1718                 push @$sort, {"class" => "acns", "field" => "label_sortkey"};
1719             } elsif ($s eq 'request_time') {
1720                 push @$sort, {"class" => "ahr", "field" => "request_time"};
1721             }
1722         }
1723     } else {
1724         push @$sort, {"class" => "ahr", "field" => "request_time"};
1725     }
1726
1727     my $holds_ids = $e->json_query(
1728         {
1729             "select" => {"ahr" => ["id"]},
1730             "from" => {
1731                 "ahr" => {
1732                     "acp" => {
1733                         "field" => "id",
1734                         "fkey" => "current_copy",
1735                         "filter" => {
1736                             "circ_lib" => $$params{org_id}, "status" => [0,7]
1737                         },
1738                         "join" => {
1739                             "acn" => {
1740                                 "field" => "id",
1741                                 "fkey" => "call_number",
1742                                 "join" => {
1743                                     "acnp" => {
1744                                         "field" => "id",
1745                                         "fkey" => "prefix"
1746                                     },
1747                                     "acns" => {
1748                                         "field" => "id",
1749                                         "fkey" => "suffix"
1750                                     }
1751                                 }
1752                             },
1753                             "acplo" => {
1754                                 "field" => "org",
1755                                 "fkey" => "circ_lib",
1756                                 "type" => "left",
1757                                 "filter" => {
1758                                     "location" => {"=" => {"+acp" => "location"}}
1759                                 }
1760                             }
1761                         }
1762                     }
1763                 }
1764             },
1765             "where" => {
1766                 "+ahr" => {
1767                     "capture_time" => undef,
1768                     "cancel_time" => undef,
1769                     "-or" => [
1770                         {"expire_time" => undef },
1771                         {"expire_time" => {">" => "now"}}
1772                     ]
1773                 }
1774             },
1775             (@$sort ? (order_by => $sort) : ()),
1776             ($$params{limit} ? (limit => $$params{limit}) : ()),
1777             ($$params{offset} ? (offset => $$params{offset}) : ())
1778         }, {"substream" => 1}
1779     ) or return $e->die_event;
1780
1781     $logger->info("about to stream back " . scalar(@$holds_ids) . " holds");
1782
1783     my @chunk;
1784     for my $hid (@$holds_ids) {
1785         push @chunk, $e->retrieve_action_hold_request([
1786             $hid->{"id"}, {
1787                 "flesh" => 3,
1788                 "flesh_fields" => {
1789                     "ahr" => ["usr", "current_copy"],
1790                     "au"  => ["card"],
1791                     "acp" => ["location", "call_number", "parts"],
1792                     "acn" => ["record","prefix","suffix"]
1793                 }
1794             }
1795         ]);
1796
1797         if (@chunk >= $$params{chunk_size}) {
1798             $client->respond( \@chunk );
1799             @chunk = ();
1800         }
1801     }
1802     $client->respond_complete( \@chunk ) if (@chunk);
1803     $e->disconnect;
1804     return undef;
1805 }
1806
1807
1808
1809 __PACKAGE__->register_method(
1810     method        => 'fetch_hold_notify',
1811     api_name      => 'open-ils.circ.hold_notification.retrieve_by_hold',
1812     authoritative => 1,
1813     signature     => q/
1814 Returns a list of hold notification objects based on hold id.
1815 @param authtoken The loggin session key
1816 @param holdid The id of the hold whose notifications we want to retrieve
1817 @return An array of hold notification objects, event on error.
1818 /
1819 );
1820
1821 sub fetch_hold_notify {
1822     my( $self, $conn, $authtoken, $holdid ) = @_;
1823     my( $requestor, $evt ) = $U->checkses($authtoken);
1824     return $evt if $evt;
1825     my ($hold, $patron);
1826     ($hold, $evt) = $U->fetch_hold($holdid);
1827     return $evt if $evt;
1828     ($patron, $evt) = $U->fetch_user($hold->usr);
1829     return $evt if $evt;
1830
1831     $evt = $U->check_perms($requestor->id, $patron->home_ou, 'VIEW_HOLD_NOTIFICATION');
1832     return $evt if $evt;
1833
1834     $logger->info("User ".$requestor->id." fetching hold notifications for hold $holdid");
1835     return $U->cstorereq(
1836         'open-ils.cstore.direct.action.hold_notification.search.atomic', {hold => $holdid} );
1837 }
1838
1839
1840 __PACKAGE__->register_method(
1841     method    => 'create_hold_notify',
1842     api_name  => 'open-ils.circ.hold_notification.create',
1843     signature => q/
1844 Creates a new hold notification object
1845 @param authtoken The login session key
1846 @param notification The hold notification object to create
1847 @return ID of the new object on success, Event on error
1848 /
1849 );
1850
1851 sub create_hold_notify {
1852    my( $self, $conn, $auth, $note ) = @_;
1853    my $e = new_editor(authtoken=>$auth, xact=>1);
1854    return $e->die_event unless $e->checkauth;
1855
1856    my $hold = $e->retrieve_action_hold_request($note->hold)
1857       or return $e->die_event;
1858    my $patron = $e->retrieve_actor_user($hold->usr)
1859       or return $e->die_event;
1860
1861    return $e->die_event unless
1862       $e->allowed('CREATE_HOLD_NOTIFICATION', $patron->home_ou);
1863
1864    $note->notify_staff($e->requestor->id);
1865    $e->create_action_hold_notification($note) or return $e->die_event;
1866    $e->commit;
1867    return $note->id;
1868 }
1869
1870 __PACKAGE__->register_method(
1871     method    => 'create_hold_note',
1872     api_name  => 'open-ils.circ.hold_note.create',
1873     signature => q/
1874         Creates a new hold request note object
1875         @param authtoken The login session key
1876         @param note The hold note object to create
1877         @return ID of the new object on success, Event on error
1878         /
1879 );
1880
1881 sub create_hold_note {
1882    my( $self, $conn, $auth, $note ) = @_;
1883    my $e = new_editor(authtoken=>$auth, xact=>1);
1884    return $e->die_event unless $e->checkauth;
1885
1886    my $hold = $e->retrieve_action_hold_request($note->hold)
1887       or return $e->die_event;
1888    my $patron = $e->retrieve_actor_user($hold->usr)
1889       or return $e->die_event;
1890
1891    return $e->die_event unless
1892       $e->allowed('UPDATE_HOLD', $patron->home_ou); # FIXME: Using permcrud perm listed in fm_IDL.xml for ahrn.  Probably want something more specific
1893
1894    $e->create_action_hold_request_note($note) or return $e->die_event;
1895    $e->commit;
1896    return $note->id;
1897 }
1898
1899 __PACKAGE__->register_method(
1900     method    => 'reset_hold',
1901     api_name  => 'open-ils.circ.hold.reset',
1902     signature => q/
1903         Un-captures and un-targets a hold, essentially returning
1904         it to the state it was in directly after it was placed,
1905         then attempts to re-target the hold
1906         @param authtoken The login session key
1907         @param holdid The id of the hold
1908     /
1909 );
1910
1911
1912 sub reset_hold {
1913     my( $self, $conn, $auth, $holdid ) = @_;
1914     my $reqr;
1915     my ($hold, $evt) = $U->fetch_hold($holdid);
1916     return $evt if $evt;
1917     ($reqr, $evt) = $U->checksesperm($auth, 'UPDATE_HOLD');
1918     return $evt if $evt;
1919     $evt = _reset_hold($self, $reqr, $hold);
1920     return $evt if $evt;
1921     return 1;
1922 }
1923
1924
1925 __PACKAGE__->register_method(
1926     method   => 'reset_hold_batch',
1927     api_name => 'open-ils.circ.hold.reset.batch'
1928 );
1929
1930 sub reset_hold_batch {
1931     my($self, $conn, $auth, $hold_ids) = @_;
1932
1933     my $e = new_editor(authtoken => $auth);
1934     return $e->event unless $e->checkauth;
1935
1936     for my $hold_id ($hold_ids) {
1937
1938         my $hold = $e->retrieve_action_hold_request(
1939             [$hold_id, {flesh => 1, flesh_fields => {ahr => ['usr']}}])
1940             or return $e->event;
1941
1942         next unless $e->allowed('UPDATE_HOLD', $hold->usr->home_ou);
1943         _reset_hold($self, $e->requestor, $hold);
1944     }
1945
1946     return 1;
1947 }
1948
1949
1950 sub _reset_hold {
1951     my ($self, $reqr, $hold) = @_;
1952
1953     my $e = new_editor(xact =>1, requestor => $reqr);
1954
1955     $logger->info("reseting hold ".$hold->id);
1956
1957     my $hid = $hold->id;
1958
1959     if( $hold->capture_time and $hold->current_copy ) {
1960
1961         my $copy = $e->retrieve_asset_copy($hold->current_copy)
1962             or return $e->die_event;
1963
1964         if( $copy->status == OILS_COPY_STATUS_ON_HOLDS_SHELF ) {
1965             $logger->info("setting copy to status 'reshelving' on hold retarget");
1966             $copy->status(OILS_COPY_STATUS_RESHELVING);
1967             $copy->editor($e->requestor->id);
1968             $copy->edit_date('now');
1969             $e->update_asset_copy($copy) or return $e->die_event;
1970
1971         } elsif( $copy->status == OILS_COPY_STATUS_IN_TRANSIT ) {
1972
1973             # We don't want the copy to remain "in transit"
1974             $copy->status(OILS_COPY_STATUS_RESHELVING);
1975             $logger->warn("! reseting hold [$hid] that is in transit");
1976             my $transid = $e->search_action_hold_transit_copy({hold=>$hold->id,cancel_time=>undef},{idlist=>1})->[0];
1977
1978             if( $transid ) {
1979                 my $trans = $e->retrieve_action_transit_copy($transid);
1980                 if( $trans ) {
1981                     $logger->info("Aborting transit [$transid] on hold [$hid] reset...");
1982                     my $evt = OpenILS::Application::Circ::Transit::__abort_transit($e, $trans, $copy, 1, 1);
1983                     $logger->info("Transit abort completed with result $evt");
1984                     unless ("$evt" eq 1) {
1985                         $e->rollback;
1986                         return $evt;
1987                     }
1988                 }
1989             }
1990         }
1991     }
1992
1993     $hold->clear_capture_time;
1994     $hold->clear_current_copy;
1995     $hold->clear_shelf_time;
1996     $hold->clear_shelf_expire_time;
1997     $hold->clear_current_shelf_lib;
1998
1999     $e->update_action_hold_request($hold) or return $e->die_event;
2000     $e->commit;
2001
2002     $U->simplereq('open-ils.hold-targeter', 
2003         'open-ils.hold-targeter.target', {hold => $hold->id});
2004
2005     return undef;
2006 }
2007
2008
2009 __PACKAGE__->register_method(
2010     method    => 'fetch_open_title_holds',
2011     api_name  => 'open-ils.circ.open_holds.retrieve',
2012     signature => q/
2013         Returns a list ids of un-fulfilled holds for a given title id
2014         @param authtoken The login session key
2015         @param id the id of the item whose holds we want to retrieve
2016         @param type The hold type - M, T, I, V, C, F, R
2017     /
2018 );
2019
2020 sub fetch_open_title_holds {
2021     my( $self, $conn, $auth, $id, $type, $org ) = @_;
2022     my $e = new_editor( authtoken => $auth );
2023     return $e->event unless $e->checkauth;
2024
2025     $type ||= "T";
2026     $org  ||= $e->requestor->ws_ou;
2027
2028 #    return $e->search_action_hold_request(
2029 #        { target => $id, hold_type => $type, fulfillment_time => undef }, {idlist=>1});
2030
2031     # XXX make me return IDs in the future ^--
2032     my $holds = $e->search_action_hold_request(
2033         {
2034             target           => $id,
2035             cancel_time      => undef,
2036             hold_type        => $type,
2037             fulfillment_time => undef
2038         }
2039     );
2040
2041     flesh_hold_transits($holds);
2042     return $holds;
2043 }
2044
2045
2046 sub flesh_hold_transits {
2047     my $holds = shift;
2048     for my $hold ( @$holds ) {
2049         $hold->transit(
2050             $apputils->simplereq(
2051                 'open-ils.cstore',
2052                 "open-ils.cstore.direct.action.hold_transit_copy.search.atomic",
2053                 { hold => $hold->id, cancel_time => undef },
2054                 { order_by => { ahtc => 'id desc' }, limit => 1 }
2055             )->[0]
2056         );
2057     }
2058 }
2059
2060 sub flesh_hold_notices {
2061     my( $holds, $e ) = @_;
2062     $e ||= new_editor();
2063
2064     for my $hold (@$holds) {
2065         my $notices = $e->search_action_hold_notification(
2066             [
2067                 { hold => $hold->id },
2068                 { order_by => { anh => 'notify_time desc' } },
2069             ],
2070             {idlist=>1}
2071         );
2072
2073         $hold->notify_count(scalar(@$notices));
2074         if( @$notices ) {
2075             my $n = $e->retrieve_action_hold_notification($$notices[0])
2076                 or return $e->event;
2077             $hold->notify_time($n->notify_time);
2078         }
2079     }
2080 }
2081
2082
2083 __PACKAGE__->register_method(
2084     method    => 'fetch_captured_holds',
2085     api_name  => 'open-ils.circ.captured_holds.on_shelf.retrieve',
2086     stream    => 1,
2087     authoritative => 1,
2088     signature => q/
2089         Returns a list of un-fulfilled holds (on the Holds Shelf) for a given title id
2090         @param authtoken The login session key
2091         @param org The org id of the location in question
2092         @param match_copy A specific copy to limit to
2093     /
2094 );
2095
2096 __PACKAGE__->register_method(
2097     method    => 'fetch_captured_holds',
2098     api_name  => 'open-ils.circ.captured_holds.id_list.on_shelf.retrieve',
2099     stream    => 1,
2100     authoritative => 1,
2101     signature => q/
2102         Returns list ids of un-fulfilled holds (on the Holds Shelf) for a given title id
2103         @param authtoken The login session key
2104         @param org The org id of the location in question
2105         @param match_copy A specific copy to limit to
2106     /
2107 );
2108
2109 __PACKAGE__->register_method(
2110     method    => 'fetch_captured_holds',
2111     api_name  => 'open-ils.circ.captured_holds.id_list.expired_on_shelf.retrieve',
2112     stream    => 1,
2113     authoritative => 1,
2114     signature => q/
2115         Returns list ids of shelf-expired un-fulfilled holds for a given title id
2116         @param authtoken The login session key
2117         @param org The org id of the location in question
2118         @param match_copy A specific copy to limit to
2119     /
2120 );
2121
2122 __PACKAGE__->register_method(
2123     method    => 'fetch_captured_holds',
2124     api_name  => 
2125       'open-ils.circ.captured_holds.expired_on_shelf_or_wrong_shelf.retrieve',
2126     stream    => 1,
2127     authoritative => 1,
2128     signature => q/
2129         Returns list of shelf-expired un-fulfilled holds OR wrong shelf holds
2130         for a given shelf lib
2131     /
2132 );
2133
2134 __PACKAGE__->register_method(
2135     method    => 'fetch_captured_holds',
2136     api_name  => 
2137       'open-ils.circ.captured_holds.id_list.expired_on_shelf_or_wrong_shelf.retrieve',
2138     stream    => 1,
2139     authoritative => 1,
2140     signature => q/
2141         Returns list of shelf-expired un-fulfilled holds OR wrong shelf holds
2142         for a given shelf lib
2143     /
2144 );
2145
2146
2147 sub fetch_captured_holds {
2148     my( $self, $conn, $auth, $org, $match_copy ) = @_;
2149
2150     my $e = new_editor(authtoken => $auth);
2151     return $e->die_event unless $e->checkauth;
2152     return $e->die_event unless $e->allowed('VIEW_HOLD'); # XXX rely on editor perm
2153
2154     $org ||= $e->requestor->ws_ou;
2155
2156     my $current_copy = { '!=' => undef };
2157     $current_copy = { '=' => $match_copy } if $match_copy;
2158
2159     my $query = {
2160         select => { alhr => ['id'] },
2161         from   => {
2162             alhr => {
2163                 acp => {
2164                     field => 'id',
2165                     fkey  => 'current_copy'
2166                 },
2167             }
2168         },
2169         where => {
2170             '+acp' => { status => OILS_COPY_STATUS_ON_HOLDS_SHELF, deleted => 'f' },
2171             '+alhr' => {
2172                 capture_time      => { "!=" => undef },
2173                 current_copy      => $current_copy,
2174                 fulfillment_time  => undef,
2175                 current_shelf_lib => $org
2176             }
2177         }
2178     };
2179     if($self->api_name =~ /expired/) {
2180         $query->{'where'}->{'+alhr'}->{'-or'} = {
2181                 shelf_expire_time => { '<' => 'today'},
2182                 cancel_time => { '!=' => undef },
2183         };
2184     }
2185     my $hold_ids = $e->json_query( $query );
2186
2187     if ($self->api_name =~ /wrong_shelf/) {
2188         # fetch holds whose current_shelf_lib is $org, but whose pickup 
2189         # lib is some other org unit.  Ignore already-retrieved holds.
2190         my $wrong_shelf =
2191             pickup_lib_changed_on_shelf_holds(
2192                 $e, $org, [map {$_->{id}} @$hold_ids]);
2193         # match the layout of other items in $hold_ids
2194         push (@$hold_ids, {id => $_}) for @$wrong_shelf;
2195     }
2196
2197
2198     for my $hold_id (@$hold_ids) {
2199         if($self->api_name =~ /id_list/) {
2200             $conn->respond($hold_id->{id});
2201             next;
2202         } else {
2203             $conn->respond(
2204                 $e->retrieve_action_hold_request([
2205                     $hold_id->{id},
2206                     {
2207                         flesh => 1,
2208                         flesh_fields => {ahr => ['notifications', 'transit', 'notes']},
2209                         order_by => {anh => 'notify_time desc'}
2210                     }
2211                 ])
2212             );
2213         }
2214     }
2215
2216     return undef;
2217 }
2218
2219 __PACKAGE__->register_method(
2220     method    => "print_expired_holds_stream",
2221     api_name  => "open-ils.circ.captured_holds.expired.print.stream",
2222     stream    => 1
2223 );
2224
2225 sub print_expired_holds_stream {
2226     my ($self, $client, $auth, $params) = @_;
2227
2228     # No need to check specific permissions: we're going to call another method
2229     # that will do that.
2230     my $e = new_editor("authtoken" => $auth);
2231     return $e->die_event unless $e->checkauth;
2232
2233     delete($$params{org_id}) unless (int($$params{org_id}));
2234     delete($$params{limit}) unless (int($$params{limit}));
2235     delete($$params{offset}) unless (int($$params{offset}));
2236     delete($$params{chunk_size}) unless (int($$params{chunk_size}));
2237     delete($$params{chunk_size}) if  ($$params{chunk_size} && $$params{chunk_size} > 50); # keep the size reasonable
2238     $$params{chunk_size} ||= 10;
2239     $client->max_chunk_size($$params{chunk_size}) if (!$client->can('max_bundle_size') && $client->can('max_chunk_size'));
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 => {
2593                                 field => 'id',
2594                                 filter => { holdable => 't', deleted => 'f' },
2595                                 fkey => 'location'
2596                             },
2597                     ccs  => { field => 'id', filter => { holdable => 't'}, fkey => 'status'   },
2598                     acpm => { field => 'target_copy', type => 'left' } # ignore part-linked copies
2599                 }
2600             },
2601             where => {
2602                 '+acp' => { circulate => 't', deleted => 'f', holdable => 't', %org_filter },
2603                 '+acpm' => { target_copy => undef } # ignore part-linked copies
2604             }
2605         }
2606     );
2607
2608     $logger->info("title possible found ".scalar(@$copies)." potential copies");
2609     return (
2610         0, 0, [
2611             new OpenILS::Event(
2612                 "HIGH_LEVEL_HOLD_HAS_NO_COPIES",
2613                 "payload" => {"fail_part" => "no_ultimate_items"}
2614             )
2615         ]
2616     ) unless @$copies;
2617
2618     # -----------------------------------------------------------------------
2619     # sort the copies into buckets based on their circ_lib proximity to
2620     # the patron's home_ou.
2621     # -----------------------------------------------------------------------
2622
2623     my $home_org = $patron->home_ou;
2624     my $req_org = $request_lib->id;
2625
2626     $prox_cache{$home_org} =
2627         $e->search_actor_org_unit_proximity({from_org => $home_org})
2628         unless $prox_cache{$home_org};
2629     my $home_prox = $prox_cache{$home_org};
2630     $logger->info("prox cache $home_org " . $prox_cache{$home_org});
2631
2632     my %buckets;
2633     my %hash = map { ($_->to_org => $_->prox) } @$home_prox;
2634     push( @{$buckets{ $hash{$_->{circ_lib}} } }, $_->{id} ) for @$copies;
2635
2636     my @keys = sort { $a <=> $b } keys %buckets;
2637
2638
2639     if( $home_org ne $req_org ) {
2640       # -----------------------------------------------------------------------
2641       # shove the copies close to the request_lib into the primary buckets
2642       # directly before the farthest away copies.  That way, they are not
2643       # given priority, but they are checked before the farthest copies.
2644       # -----------------------------------------------------------------------
2645         $prox_cache{$req_org} =
2646             $e->search_actor_org_unit_proximity({from_org => $req_org})
2647             unless $prox_cache{$req_org};
2648         my $req_prox = $prox_cache{$req_org};
2649
2650         my %buckets2;
2651         my %hash2 = map { ($_->to_org => $_->prox) } @$req_prox;
2652         push( @{$buckets2{ $hash2{$_->{circ_lib}} } }, $_->{id} ) for @$copies;
2653
2654         my $highest_key = $keys[@keys - 1];  # the farthest prox in the exising buckets
2655         my $new_key = $highest_key - 0.5; # right before the farthest prox
2656         my @keys2   = sort { $a <=> $b } keys %buckets2;
2657         for my $key (@keys2) {
2658             last if $key >= $highest_key;
2659             push( @{$buckets{$new_key}}, $_ ) for @{$buckets2{$key}};
2660         }
2661     }
2662
2663     @keys = sort { $a <=> $b } keys %buckets;
2664
2665     my $title;
2666     my %seen;
2667     my @status;
2668     my $age_protect_only = 0;
2669     OUTER: for my $key (@keys) {
2670       my @cps = @{$buckets{$key}};
2671
2672       $logger->info("looking at " . scalar(@{$buckets{$key}}). " copies in proximity bucket $key");
2673
2674       for my $copyid (@cps) {
2675
2676          next if $seen{$copyid};
2677          $seen{$copyid} = 1; # there could be dupes given the merged buckets
2678          my $copy = $e->retrieve_asset_copy($copyid);
2679          $logger->debug("looking at bucket_key=$key, copy $copyid : circ_lib = " . $copy->circ_lib);
2680
2681          unless($title) { # grab the title if we don't already have it
2682             my $vol = $e->retrieve_asset_call_number(
2683                [ $copy->call_number, { flesh => 1, flesh_fields => { bre => ['fixed_fields'], acn => ['record'] } } ] );
2684             $title = $vol->record;
2685          }
2686
2687          @status = verify_copy_for_hold(
2688             $patron, $requestor, $title, $copy, $pickup_lib, $request_lib, $oargs);
2689
2690          $age_protect_only ||= $status[3];
2691          last OUTER if $status[0];
2692       }
2693     }
2694
2695     $status[3] = $age_protect_only;
2696     return @status;
2697 }
2698
2699 sub _check_issuance_hold_is_possible {
2700     my( $issuanceid, $depth, $request_lib, $patron, $requestor, $pickup_lib, $selection_ou, $oargs ) = @_;
2701
2702     my $e = new_editor();
2703     my %org_filter = create_ranged_org_filter($e, $selection_ou, $depth);
2704
2705     # this monster will grab the id and circ_lib of all of the "holdable" copies for the given record
2706     my $copies = $e->json_query(
2707         {
2708             select => { acp => ['id', 'circ_lib'] },
2709               from => {
2710                 acp => {
2711                     sitem => {
2712                         field  => 'unit',
2713                         fkey   => 'id',
2714                         filter => { issuance => $issuanceid }
2715                     },
2716                     acpl => {
2717                         field => 'id',
2718                         filter => { holdable => 't', deleted => 'f' },
2719                         fkey => 'location'
2720                     },
2721                     ccs  => { field => 'id', filter => { holdable => 't'}, fkey => 'status'   }
2722                 }
2723             },
2724             where => {
2725                 '+acp' => { circulate => 't', deleted => 'f', holdable => 't', %org_filter }
2726             },
2727             distinct => 1
2728         }
2729     );
2730
2731     $logger->info("issuance possible found ".scalar(@$copies)." potential copies");
2732
2733     my $empty_ok;
2734     if (!@$copies) {
2735         $empty_ok = $e->retrieve_config_global_flag('circ.holds.empty_issuance_ok');
2736         $empty_ok = ($empty_ok and $U->is_true($empty_ok->enabled));
2737
2738         return (
2739             0, 0, [
2740                 new OpenILS::Event(
2741                     "HIGH_LEVEL_HOLD_HAS_NO_COPIES",
2742                     "payload" => {"fail_part" => "no_ultimate_items"}
2743                 )
2744             ]
2745         ) unless $empty_ok;
2746
2747         return (1, 0);
2748     }
2749
2750     # -----------------------------------------------------------------------
2751     # sort the copies into buckets based on their circ_lib proximity to
2752     # the patron's home_ou.
2753     # -----------------------------------------------------------------------
2754
2755     my $home_org = $patron->home_ou;
2756     my $req_org = $request_lib->id;
2757
2758     $prox_cache{$home_org} =
2759         $e->search_actor_org_unit_proximity({from_org => $home_org})
2760         unless $prox_cache{$home_org};
2761     my $home_prox = $prox_cache{$home_org};
2762     $logger->info("prox cache $home_org " . $prox_cache{$home_org});
2763
2764     my %buckets;
2765     my %hash = map { ($_->to_org => $_->prox) } @$home_prox;
2766     push( @{$buckets{ $hash{$_->{circ_lib}} } }, $_->{id} ) for @$copies;
2767
2768     my @keys = sort { $a <=> $b } keys %buckets;
2769
2770
2771     if( $home_org ne $req_org ) {
2772       # -----------------------------------------------------------------------
2773       # shove the copies close to the request_lib into the primary buckets
2774       # directly before the farthest away copies.  That way, they are not
2775       # given priority, but they are checked before the farthest copies.
2776       # -----------------------------------------------------------------------
2777         $prox_cache{$req_org} =
2778             $e->search_actor_org_unit_proximity({from_org => $req_org})
2779             unless $prox_cache{$req_org};
2780         my $req_prox = $prox_cache{$req_org};
2781
2782         my %buckets2;
2783         my %hash2 = map { ($_->to_org => $_->prox) } @$req_prox;
2784         push( @{$buckets2{ $hash2{$_->{circ_lib}} } }, $_->{id} ) for @$copies;
2785
2786         my $highest_key = $keys[@keys - 1];  # the farthest prox in the exising buckets
2787         my $new_key = $highest_key - 0.5; # right before the farthest prox
2788         my @keys2   = sort { $a <=> $b } keys %buckets2;
2789         for my $key (@keys2) {
2790             last if $key >= $highest_key;
2791             push( @{$buckets{$new_key}}, $_ ) for @{$buckets2{$key}};
2792         }
2793     }
2794
2795     @keys = sort { $a <=> $b } keys %buckets;
2796
2797     my $title;
2798     my %seen;
2799     my @status;
2800     my $age_protect_only = 0;
2801     OUTER: for my $key (@keys) {
2802       my @cps = @{$buckets{$key}};
2803
2804       $logger->info("looking at " . scalar(@{$buckets{$key}}). " copies in proximity bucket $key");
2805
2806       for my $copyid (@cps) {
2807
2808          next if $seen{$copyid};
2809          $seen{$copyid} = 1; # there could be dupes given the merged buckets
2810          my $copy = $e->retrieve_asset_copy($copyid);
2811          $logger->debug("looking at bucket_key=$key, copy $copyid : circ_lib = " . $copy->circ_lib);
2812
2813          unless($title) { # grab the title if we don't already have it
2814             my $vol = $e->retrieve_asset_call_number(
2815                [ $copy->call_number, { flesh => 1, flesh_fields => { bre => ['fixed_fields'], acn => ['record'] } } ] );
2816             $title = $vol->record;
2817          }
2818
2819          @status = verify_copy_for_hold(
2820             $patron, $requestor, $title, $copy, $pickup_lib, $request_lib, $oargs);
2821
2822          $age_protect_only ||= $status[3];
2823          last OUTER if $status[0];
2824       }
2825     }
2826
2827     if (!$status[0]) {
2828         if (!defined($empty_ok)) {
2829             $empty_ok = $e->retrieve_config_global_flag('circ.holds.empty_issuance_ok');
2830             $empty_ok = ($empty_ok and $U->is_true($empty_ok->enabled));
2831         }
2832
2833         return (1,0) if ($empty_ok);
2834     }
2835     $status[3] = $age_protect_only;
2836     return @status;
2837 }
2838
2839 sub _check_monopart_hold_is_possible {
2840     my( $partid, $depth, $request_lib, $patron, $requestor, $pickup_lib, $selection_ou, $oargs ) = @_;
2841
2842     my $e = new_editor();
2843     my %org_filter = create_ranged_org_filter($e, $selection_ou, $depth);
2844
2845     # this monster will grab the id and circ_lib of all of the "holdable" copies for the given record
2846     my $copies = $e->json_query(
2847         {
2848             select => { acp => ['id', 'circ_lib'] },
2849               from => {
2850                 acp => {
2851                     acpm => {
2852                         field  => 'target_copy',
2853                         fkey   => 'id',
2854                         filter => { part => $partid }
2855                     },
2856                     acpl => {
2857                         field => 'id',
2858                         filter => { holdable => 't', deleted => 'f' },
2859                         fkey => 'location'
2860                     },
2861                     ccs  => { field => 'id', filter => { holdable => 't'}, fkey => 'status'   }
2862                 }
2863             },
2864             where => {
2865                 '+acp' => { circulate => 't', deleted => 'f', holdable => 't', %org_filter }
2866             },
2867             distinct => 1
2868         }
2869     );
2870
2871     $logger->info("monopart possible found ".scalar(@$copies)." potential copies");
2872
2873     my $empty_ok;
2874     if (!@$copies) {
2875         $empty_ok = $e->retrieve_config_global_flag('circ.holds.empty_part_ok');
2876         $empty_ok = ($empty_ok and $U->is_true($empty_ok->enabled));
2877
2878         return (
2879             0, 0, [
2880                 new OpenILS::Event(
2881                     "HIGH_LEVEL_HOLD_HAS_NO_COPIES",
2882                     "payload" => {"fail_part" => "no_ultimate_items"}
2883                 )
2884             ]
2885         ) unless $empty_ok;
2886
2887         return (1, 0);
2888     }
2889
2890     # -----------------------------------------------------------------------
2891     # sort the copies into buckets based on their circ_lib proximity to
2892     # the patron's home_ou.
2893     # -----------------------------------------------------------------------
2894
2895     my $home_org = $patron->home_ou;
2896     my $req_org = $request_lib->id;
2897
2898     $prox_cache{$home_org} =
2899         $e->search_actor_org_unit_proximity({from_org => $home_org})
2900         unless $prox_cache{$home_org};
2901     my $home_prox = $prox_cache{$home_org};
2902     $logger->info("prox cache $home_org " . $prox_cache{$home_org});
2903
2904     my %buckets;
2905     my %hash = map { ($_->to_org => $_->prox) } @$home_prox;
2906     push( @{$buckets{ $hash{$_->{circ_lib}} } }, $_->{id} ) for @$copies;
2907
2908     my @keys = sort { $a <=> $b } keys %buckets;
2909
2910
2911     if( $home_org ne $req_org ) {
2912       # -----------------------------------------------------------------------
2913       # shove the copies close to the request_lib into the primary buckets
2914       # directly before the farthest away copies.  That way, they are not
2915       # given priority, but they are checked before the farthest copies.
2916       # -----------------------------------------------------------------------
2917         $prox_cache{$req_org} =
2918             $e->search_actor_org_unit_proximity({from_org => $req_org})
2919             unless $prox_cache{$req_org};
2920         my $req_prox = $prox_cache{$req_org};
2921
2922         my %buckets2;
2923         my %hash2 = map { ($_->to_org => $_->prox) } @$req_prox;
2924         push( @{$buckets2{ $hash2{$_->{circ_lib}} } }, $_->{id} ) for @$copies;
2925
2926         my $highest_key = $keys[@keys - 1];  # the farthest prox in the exising buckets
2927         my $new_key = $highest_key - 0.5; # right before the farthest prox
2928         my @keys2   = sort { $a <=> $b } keys %buckets2;
2929         for my $key (@keys2) {
2930             last if $key >= $highest_key;
2931             push( @{$buckets{$new_key}}, $_ ) for @{$buckets2{$key}};
2932         }
2933     }
2934
2935     @keys = sort { $a <=> $b } keys %buckets;
2936
2937     my $title;
2938     my %seen;
2939     my @status;
2940     my $age_protect_only = 0;
2941     OUTER: for my $key (@keys) {
2942       my @cps = @{$buckets{$key}};
2943
2944       $logger->info("looking at " . scalar(@{$buckets{$key}}). " copies in proximity bucket $key");
2945
2946       for my $copyid (@cps) {
2947
2948          next if $seen{$copyid};
2949          $seen{$copyid} = 1; # there could be dupes given the merged buckets
2950          my $copy = $e->retrieve_asset_copy($copyid);
2951          $logger->debug("looking at bucket_key=$key, copy $copyid : circ_lib = " . $copy->circ_lib);
2952
2953          unless($title) { # grab the title if we don't already have it
2954             my $vol = $e->retrieve_asset_call_number(
2955                [ $copy->call_number, { flesh => 1, flesh_fields => { bre => ['fixed_fields'], acn => ['record'] } } ] );
2956             $title = $vol->record;
2957          }
2958
2959          @status = verify_copy_for_hold(
2960             $patron, $requestor, $title, $copy, $pickup_lib, $request_lib, $oargs);
2961
2962          $age_protect_only ||= $status[3];
2963          last OUTER if $status[0];
2964       }
2965     }
2966
2967     if (!$status[0]) {
2968         if (!defined($empty_ok)) {
2969             $empty_ok = $e->retrieve_config_global_flag('circ.holds.empty_part_ok');
2970             $empty_ok = ($empty_ok and $U->is_true($empty_ok->enabled));
2971         }
2972
2973         return (1,0) if ($empty_ok);
2974     }
2975     $status[3] = $age_protect_only;
2976     return @status;
2977 }
2978
2979
2980 sub _check_volume_hold_is_possible {
2981     my( $vol, $title, $depth, $request_lib, $patron, $requestor, $pickup_lib, $selection_ou, $oargs ) = @_;
2982     my %org_filter = create_ranged_org_filter(new_editor(), $selection_ou, $depth);
2983     my $copies = new_editor->search_asset_copy({call_number => $vol->id, %org_filter});
2984     $logger->info("checking possibility of volume hold for volume ".$vol->id);
2985
2986     my $filter_copies = [];
2987     for my $copy (@$copies) {
2988         # ignore part-mapped copies for regular volume level holds
2989         push(@$filter_copies, $copy) unless
2990             new_editor->search_asset_copy_part_map({target_copy => $copy->id})->[0];
2991     }
2992     $copies = $filter_copies;
2993
2994     return (
2995         0, 0, [
2996             new OpenILS::Event(
2997                 "HIGH_LEVEL_HOLD_HAS_NO_COPIES",
2998                 "payload" => {"fail_part" => "no_ultimate_items"}
2999             )
3000         ]
3001     ) unless @$copies;
3002
3003     my @status;
3004     my $age_protect_only = 0;
3005     for my $copy ( @$copies ) {
3006         @status = verify_copy_for_hold(
3007             $patron, $requestor, $title, $copy, $pickup_lib, $request_lib, $oargs );
3008         $age_protect_only ||= $status[3];
3009         last if $status[0];
3010     }
3011     $status[3] = $age_protect_only;
3012     return @status;
3013 }
3014
3015
3016
3017 sub verify_copy_for_hold {
3018     my( $patron, $requestor, $title, $copy, $pickup_lib, $request_lib, $oargs ) = @_;
3019     # $oargs should be undef unless we're overriding.
3020     $logger->info("checking possibility of copy in hold request for copy ".$copy->id);
3021     my $permitted = OpenILS::Utils::PermitHold::permit_copy_hold(
3022         {
3023             patron           => $patron,
3024             requestor        => $requestor,
3025             copy             => $copy,
3026             title            => $title,
3027             title_descriptor => $title->fixed_fields,
3028             pickup_lib       => $pickup_lib,
3029             request_lib      => $request_lib,
3030             new_hold         => 1,
3031             show_event_list  => 1
3032         }
3033     );
3034
3035     # Check for override permissions on events.
3036     if ($oargs && $permitted && scalar @$permitted) {
3037         # Remove the events from permitted that we can override.
3038         if ($oargs->{events}) {
3039             foreach my $evt (@{$oargs->{events}}) {
3040                 $permitted = [grep {$_->{textcode} ne $evt} @{$permitted}];
3041             }
3042         }
3043         # Now, we handle the override all case by checking remaining
3044         # events against override permissions.
3045         if (scalar @$permitted && $oargs->{all}) {
3046             # Pre-set events and failed members of oargs to empty
3047             # arrays, if they are not set, yet.
3048             $oargs->{events} = [] unless ($oargs->{events});
3049             $oargs->{failed} = [] unless ($oargs->{failed});
3050             # When we're done with these checks, we swap permitted
3051             # with a reference to @disallowed.
3052             my @disallowed = ();
3053             foreach my $evt (@{$permitted}) {
3054                 # Check if we've already seen the event in this
3055                 # session and it failed.
3056                 if (grep {$_ eq $evt->{textcode}} @{$oargs->{failed}}) {
3057                     push(@disallowed, $evt);
3058                 } else {
3059                     # We have to check if the requestor has the
3060                     # override permission.
3061
3062                     # AppUtils::check_user_perms returns the perm if
3063                     # the user doesn't have it, undef if they do.
3064                     if ($apputils->check_user_perms($requestor->id, $requestor->ws_ou, $evt->{textcode} . '.override')) {
3065                         push(@disallowed, $evt);
3066                         push(@{$oargs->{failed}}, $evt->{textcode});
3067                     } else {
3068                         push(@{$oargs->{events}}, $evt->{textcode});
3069                     }
3070                 }
3071             }
3072             $permitted = \@disallowed;
3073         }
3074     }
3075
3076     my $age_protect_only = 0;
3077     if (@$permitted == 1 && @$permitted[0]->{textcode} eq 'ITEM_AGE_PROTECTED') {
3078         $age_protect_only = 1;
3079     }
3080
3081     return (
3082         (not scalar @$permitted), # true if permitted is an empty arrayref
3083         (   # XXX This test is of very dubious value; someone should figure
3084             # out what if anything is checking this value
3085             ($copy->circ_lib == $pickup_lib) and
3086             ($copy->status == OILS_COPY_STATUS_AVAILABLE)
3087         ),
3088         $permitted,
3089         $age_protect_only
3090     );
3091 }
3092
3093
3094
3095 sub find_nearest_permitted_hold {
3096
3097     my $class  = shift;
3098     my $editor = shift;     # CStoreEditor object
3099     my $copy   = shift;     # copy to target
3100     my $user   = shift;     # staff
3101     my $check_only = shift; # do no updates, just see if the copy could fulfill a hold
3102
3103     my $evt = OpenILS::Event->new('ACTION_HOLD_REQUEST_NOT_FOUND');
3104
3105     my $bc = $copy->barcode;
3106
3107     # find any existing holds that already target this copy
3108     my $old_holds = $editor->search_action_hold_request(
3109         {    current_copy => $copy->id,
3110             cancel_time  => undef,
3111             capture_time => undef
3112         }
3113     );
3114
3115     my $hold_stall_interval = $U->ou_ancestor_setting_value($user->ws_ou, OILS_SETTING_HOLD_SOFT_STALL);
3116
3117     $logger->info("circulator: searching for best hold at org ".$user->ws_ou.
3118         " and copy $bc with a hold stalling interval of ". ($hold_stall_interval || "(none)"));
3119
3120     my $fifo = $U->ou_ancestor_setting_value($user->ws_ou, 'circ.holds_fifo');
3121
3122     # the nearest_hold API call now needs this
3123     $copy->call_number($editor->retrieve_asset_call_number($copy->call_number))
3124         unless ref $copy->call_number;
3125
3126     # search for what should be the best holds for this copy to fulfill
3127     my $best_holds = $U->storagereq(
3128         "open-ils.storage.action.hold_request.nearest_hold.atomic", 
3129         $user->ws_ou, $copy, 100, $hold_stall_interval, $fifo );
3130
3131     # Add any pre-targeted holds to the list too? Unless they are already there, anyway.
3132     if ($old_holds) {
3133         for my $holdid (@$old_holds) {
3134             next unless $holdid;
3135             push(@$best_holds, $holdid) unless ( grep { ''.$holdid eq ''.$_ } @$best_holds );
3136         }
3137     }
3138
3139     unless(@$best_holds) {
3140         $logger->info("circulator: no suitable holds found for copy $bc");
3141         return (undef, $evt);
3142     }
3143
3144
3145     my $best_hold;
3146
3147     # for each potential hold, we have to run the permit script
3148     # to make sure the hold is actually permitted.
3149     my %reqr_cache;
3150     my %org_cache;
3151     for my $holdid (@$best_holds) {
3152         next unless $holdid;
3153         $logger->info("circulator: checking if hold $holdid is permitted for copy $bc");
3154
3155         my $hold = $editor->retrieve_action_hold_request($holdid) or next;
3156         # Force and recall holds bypass all rules
3157         if ($hold->hold_type eq 'R' || $hold->hold_type eq 'F') {
3158             $best_hold = $hold;
3159             last;
3160         }
3161         my $reqr = $reqr_cache{$hold->requestor} || $editor->retrieve_actor_user($hold->requestor);
3162         my $rlib = $org_cache{$hold->request_lib} || $editor->retrieve_actor_org_unit($hold->request_lib);
3163
3164         $reqr_cache{$hold->requestor} = $reqr;
3165         $org_cache{$hold->request_lib} = $rlib;
3166
3167         # see if this hold is permitted
3168         my $permitted = OpenILS::Utils::PermitHold::permit_copy_hold(
3169             {
3170                 patron_id   => $hold->usr,
3171                 requestor   => $reqr,
3172                 copy        => $copy,
3173                 pickup_lib  => $hold->pickup_lib,
3174                 request_lib => $rlib,
3175                 retarget    => 1
3176             }
3177         );
3178
3179         if( $permitted ) {
3180             $best_hold = $hold;
3181             last;
3182         }
3183     }
3184
3185
3186     unless( $best_hold ) { # no "good" permitted holds were found
3187         # we got nuthin
3188         $logger->info("circulator: no suitable holds found for copy $bc");
3189         return (undef, $evt);
3190     }
3191
3192     $logger->info("circulator: best hold ".$best_hold->id." found for copy $bc");
3193
3194     # indicate a permitted hold was found
3195     return $best_hold if $check_only;
3196
3197     # we've found a permitted hold.  we need to "grab" the copy
3198     # to prevent re-targeted holds (next part) from re-grabbing the copy
3199     $best_hold->current_copy($copy->id);
3200     $editor->update_action_hold_request($best_hold)
3201         or return (undef, $editor->event);
3202
3203
3204     my @retarget;
3205
3206     # re-target any other holds that already target this copy
3207     for my $old_hold (@$old_holds) {
3208         next if $old_hold->id eq $best_hold->id; # don't re-target the hold we want
3209         $logger->info("circulator: clearing current_copy and prev_check_time on hold ".
3210             $old_hold->id." after a better hold [".$best_hold->id."] was found");
3211         $old_hold->clear_current_copy;
3212         $old_hold->clear_prev_check_time;
3213         $editor->update_action_hold_request($old_hold)
3214             or return (undef, $editor->event);
3215         push(@retarget, $old_hold->id);
3216     }
3217
3218     return ($best_hold, undef, (@retarget) ? \@retarget : undef);
3219 }
3220
3221
3222
3223
3224
3225
3226 __PACKAGE__->register_method(
3227     method   => 'all_rec_holds',
3228     api_name => 'open-ils.circ.holds.retrieve_all_from_title',
3229 );
3230
3231 sub all_rec_holds {
3232     my( $self, $conn, $auth, $title_id, $args ) = @_;
3233
3234     my $e = new_editor(authtoken=>$auth);
3235     $e->checkauth or return $e->event;
3236     $e->allowed('VIEW_HOLD') or return $e->event;
3237
3238     $args ||= {};
3239     $args->{fulfillment_time} = undef; #  we don't want to see old fulfilled holds
3240     $args->{cancel_time} = undef;
3241
3242     my $resp = {
3243           metarecord_holds => []
3244         , title_holds      => []
3245         , volume_holds     => []
3246         , copy_holds       => []
3247         , recall_holds     => []
3248         , force_holds      => []
3249         , part_holds       => []
3250         , issuance_holds   => []
3251     };
3252
3253     my $mr_map = $e->search_metabib_metarecord_source_map({source => $title_id})->[0];
3254     if($mr_map) {
3255         $resp->{metarecord_holds} = $e->search_action_hold_request(
3256             {   hold_type => OILS_HOLD_TYPE_METARECORD,
3257                 target => $mr_map->metarecord,
3258                 %$args
3259             }, {idlist => 1}
3260         );
3261     }
3262
3263     $resp->{title_holds} = $e->search_action_hold_request(
3264         {
3265             hold_type => OILS_HOLD_TYPE_TITLE,
3266             target => $title_id,
3267             %$args
3268         }, {idlist=>1} );
3269
3270     my $parts = $e->search_biblio_monograph_part(
3271         {
3272             record => $title_id
3273         }, {idlist=>1} );
3274
3275     if (@$parts) {
3276         $resp->{part_holds} = $e->search_action_hold_request(
3277             {
3278                 hold_type => OILS_HOLD_TYPE_MONOPART,
3279                 target => $parts,
3280                 %$args
3281             }, {idlist=>1} );
3282     }
3283
3284     my $subs = $e->search_serial_subscription(
3285         { record_entry => $title_id }, {idlist=>1});
3286
3287     if (@$subs) {
3288         my $issuances = $e->search_serial_issuance(
3289             {subscription => $subs}, {idlist=>1}
3290         );
3291
3292         if (@$issuances) {
3293             $resp->{issuance_holds} = $e->search_action_hold_request(
3294                 {
3295                     hold_type => OILS_HOLD_TYPE_ISSUANCE,
3296                     target => $issuances,
3297                     %$args
3298                 }, {idlist=>1}
3299             );
3300         }
3301     }
3302
3303     my $vols = $e->search_asset_call_number(
3304         { record => $title_id, deleted => 'f' }, {idlist=>1});
3305
3306     return $resp unless @$vols;
3307
3308     $resp->{volume_holds} = $e->search_action_hold_request(
3309         {
3310             hold_type => OILS_HOLD_TYPE_VOLUME,
3311             target => $vols,
3312             %$args },
3313         {idlist=>1} );
3314
3315     my $copies = $e->search_asset_copy(
3316         { call_number => $vols, deleted => 'f' }, {idlist=>1});
3317
3318     return $resp unless @$copies;
3319
3320     $resp->{copy_holds} = $e->search_action_hold_request(
3321         {
3322             hold_type => OILS_HOLD_TYPE_COPY,
3323             target => $copies,
3324             %$args },
3325         {idlist=>1} );
3326
3327     $resp->{recall_holds} = $e->search_action_hold_request(
3328         {
3329             hold_type => OILS_HOLD_TYPE_RECALL,
3330             target => $copies,
3331             %$args },
3332         {idlist=>1} );
3333
3334     $resp->{force_holds} = $e->search_action_hold_request(
3335         {
3336             hold_type => OILS_HOLD_TYPE_FORCE,
3337             target => $copies,
3338             %$args },
3339         {idlist=>1} );
3340
3341     return $resp;
3342 }
3343
3344
3345
3346
3347
3348 __PACKAGE__->register_method(
3349     method        => 'uber_hold',
3350     authoritative => 1,
3351     api_name      => 'open-ils.circ.hold.details.retrieve'
3352 );
3353
3354 sub uber_hold {
3355     my($self, $client, $auth, $hold_id, $args) = @_;
3356     my $e = new_editor(authtoken=>$auth);
3357     $e->checkauth or return $e->event;
3358     return uber_hold_impl($e, $hold_id, $args);
3359 }
3360
3361 __PACKAGE__->register_method(
3362     method        => 'batch_uber_hold',
3363     authoritative => 1,
3364     stream        => 1,
3365     api_name      => 'open-ils.circ.hold.details.batch.retrieve'
3366 );
3367
3368 sub batch_uber_hold {
3369     my($self, $client, $auth, $hold_ids, $args) = @_;
3370     my $e = new_editor(authtoken=>$auth);
3371     $e->checkauth or return $e->event;
3372     $client->respond(uber_hold_impl($e, $_, $args)) for @$hold_ids;
3373     return undef;
3374 }
3375
3376 sub uber_hold_impl {
3377     my($e, $hold_id, $args) = @_;
3378     $args ||= {};
3379
3380     my $flesh_fields = ['current_copy', 'usr', 'notes'];
3381     push (@$flesh_fields, 'requestor') if $args->{include_requestor};
3382     push (@$flesh_fields, 'cancel_cause') if $args->{include_cancel_cause};
3383
3384     my $hold = $e->retrieve_action_hold_request([
3385         $hold_id,
3386         {flesh => 1, flesh_fields => {ahr => $flesh_fields}}
3387     ]) or return $e->event;
3388
3389     if($hold->usr->id ne $e->requestor->id) {
3390         # caller is asking for someone else's hold
3391         $e->allowed('VIEW_HOLD') or return $e->event;
3392         $hold->notes( # filter out any non-staff ("private") notes (unless marked as public)
3393             [ grep { $U->is_true($_->staff) or $U->is_true($_->pub) } @{$hold->notes} ] );
3394
3395     } else {
3396         # caller is asking for own hold, but may not have permission to view staff notes
3397         unless($e->allowed('VIEW_HOLD')) {
3398             $hold->notes( # filter out any staff notes (unless marked as public)
3399                 [ grep { !$U->is_true($_->staff) or $U->is_true($_->pub) } @{$hold->notes} ] );
3400         }
3401     }
3402
3403     my $user = $hold->usr;
3404     $hold->usr($user->id);
3405
3406
3407     my( $mvr, $volume, $copy, $issuance, $part, $bre ) = find_hold_mvr($e, $hold, $args);
3408
3409     flesh_hold_notices([$hold], $e) unless $args->{suppress_notices};
3410     flesh_hold_transits([$hold]) unless $args->{suppress_transits};
3411
3412     my $details = retrieve_hold_queue_status_impl($e, $hold);
3413     $hold->usr($user) if $args->{include_usr}; # re-flesh
3414
3415     my $resp = {
3416         hold    => $hold,
3417         bre_id  => $bre->id,
3418         ($copy     ? (copy           => $copy)     : ()),
3419         ($volume   ? (volume         => $volume)   : ()),
3420         ($issuance ? (issuance       => $issuance) : ()),
3421         ($part     ? (part           => $part)     : ()),
3422         ($args->{include_bre}  ?  (bre => $bre)    : ()),
3423         ($args->{suppress_mvr} ?  () : (mvr => $mvr)),
3424         %$details
3425     };
3426
3427     $resp->{copy}->location(
3428         $e->retrieve_asset_copy_location($resp->{copy}->location))
3429         if $resp->{copy} and $args->{flesh_acpl};
3430
3431     unless($args->{suppress_patron_details}) {
3432         my $card = $e->retrieve_actor_card($user->card) or return $e->event;
3433         $resp->{patron_first}   = $user->first_given_name,
3434         $resp->{patron_last}    = $user->family_name,
3435         $resp->{patron_barcode} = $card->barcode,
3436         $resp->{patron_alias}   = $user->alias,
3437     };
3438
3439     return $resp;
3440 }
3441
3442
3443
3444 # -----------------------------------------------------
3445 # Returns the MVR object that represents what the
3446 # hold is all about
3447 # -----------------------------------------------------
3448 sub find_hold_mvr {
3449     my( $e, $hold, $args ) = @_;
3450
3451     my $tid;
3452     my $copy;
3453     my $volume;
3454     my $issuance;
3455     my $part;
3456     my $no_mvr = $args->{suppress_mvr};
3457
3458     if( $hold->hold_type eq OILS_HOLD_TYPE_METARECORD ) {
3459         my $mr = $e->retrieve_metabib_metarecord($hold->target)
3460             or return $e->event;
3461         $tid = $mr->master_record;
3462
3463     } elsif( $hold->hold_type eq OILS_HOLD_TYPE_TITLE ) {
3464         $tid = $hold->target;
3465
3466     } elsif( $hold->hold_type eq OILS_HOLD_TYPE_VOLUME ) {
3467         $volume = $e->retrieve_asset_call_number($hold->target)
3468             or return $e->event;
3469         $tid = $volume->record;
3470
3471     } elsif( $hold->hold_type eq OILS_HOLD_TYPE_ISSUANCE ) {
3472         $issuance = $e->retrieve_serial_issuance([
3473             $hold->target,
3474             {flesh => 1, flesh_fields => {siss => [ qw/subscription/ ]}}
3475         ]) or return $e->event;
3476
3477         $tid = $issuance->subscription->record_entry;
3478
3479     } elsif( $hold->hold_type eq OILS_HOLD_TYPE_MONOPART ) {
3480         $part = $e->retrieve_biblio_monograph_part([
3481             $hold->target
3482         ]) or return $e->event;
3483
3484         $tid = $part->record;
3485
3486     } 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 ) {
3487         $copy = $e->retrieve_asset_copy([
3488             $hold->target,
3489             {flesh => 1, flesh_fields => {acp => ['call_number']}}
3490         ]) or return $e->event;
3491
3492         $volume = $copy->call_number;
3493         $tid = $volume->record;
3494     }
3495
3496     if(!$copy and ref $hold->current_copy ) {
3497         $copy = $hold->current_copy;
3498         $hold->current_copy($copy->id) unless $args->{include_current_copy};
3499     }
3500
3501     if(!$volume and $copy) {
3502         $volume = $e->retrieve_asset_call_number($copy->call_number);
3503     }
3504
3505     # TODO return metarcord mvr for M holds
3506     my $title = $e->retrieve_biblio_record_entry($tid);
3507     return ( ($no_mvr) ? undef : $U->record_to_mvr($title), $volume, $copy, $issuance, $part, $title );
3508 }
3509
3510 __PACKAGE__->register_method(
3511     method    => 'clear_shelf_cache',
3512     api_name  => 'open-ils.circ.hold.clear_shelf.get_cache',
3513     stream    => 1,
3514     signature => {
3515         desc => q/
3516             Returns the holds processed with the given cache key
3517         /
3518     }
3519 );
3520
3521 sub clear_shelf_cache {
3522     my($self, $client, $auth, $cache_key, $chunk_size) = @_;
3523     my $e = new_editor(authtoken => $auth, xact => 1);
3524     return $e->die_event unless $e->checkauth and $e->allowed('VIEW_HOLD');
3525
3526     $chunk_size ||= 25;
3527     $client->max_chunk_size($chunk_size) if (!$client->can('max_bundle_size') && $client->can('max_chunk_size'));
3528
3529     my $hold_data = OpenSRF::Utils::Cache->new('global')->get_cache($cache_key);
3530
3531     if (!$hold_data) {
3532         $logger->info("no hold data found in cache"); # XXX TODO return event
3533         $e->rollback;
3534         return undef;
3535     }
3536
3537     my $maximum = 0;
3538     foreach (keys %$hold_data) {
3539         $maximum += scalar(@{ $hold_data->{$_} });
3540     }
3541     $client->respond({"maximum" => $maximum, "progress" => 0});
3542
3543     for my $action (sort keys %$hold_data) {
3544         while (@{$hold_data->{$action}}) {
3545             my @hid_chunk = splice @{$hold_data->{$action}}, 0, $chunk_size;
3546
3547             my $result_chunk = $e->json_query({
3548                 "select" => {
3549                     "acp" => ["barcode"],
3550                     "au" => [qw/
3551                         first_given_name second_given_name family_name alias
3552                     /],
3553                     "acn" => ["label"],
3554                     "acnp" => [{column => "label", alias => "prefix"}],
3555                     "acns" => [{column => "label", alias => "suffix"}],
3556                     "bre" => ["marc"],
3557                     "acpl" => ["name"],
3558                     "ahr" => ["id"]
3559                 },
3560                 "from" => {
3561                     "ahr" => {
3562                         "acp" => {
3563                             "field" => "id", "fkey" => "current_copy",
3564                             "join" => {
3565                                 "acn" => {
3566                                     "field" => "id", "fkey" => "call_number",
3567                                     "join" => {
3568                                         "bre" => {
3569                                             "field" => "id", "fkey" => "record"
3570                                         },
3571                                         "acnp" => {
3572                                             "field" => "id", "fkey" => "prefix"
3573                                         },
3574                                         "acns" => {
3575                                             "field" => "id", "fkey" => "suffix"
3576                                         }
3577                                     }
3578                                 },
3579                                 "acpl" => {"field" => "id", "fkey" => "location"}
3580                             }
3581                         },
3582                         "au" => {"field" => "id", "fkey" => "usr"}
3583                     }
3584                 },
3585                 "where" => {"+ahr" => {"id" => \@hid_chunk}}
3586             }, {"substream" => 1}) or return $e->die_event;
3587
3588             $client->respond([
3589                 map {
3590                     +{"action" => $action, "hold_details" => $_}
3591                 } @$result_chunk
3592             ]);
3593         }
3594     }
3595
3596     $e->rollback;
3597     return undef;
3598 }
3599
3600
3601 __PACKAGE__->register_method(
3602     method    => 'clear_shelf_process',
3603     stream    => 1,
3604     api_name  => 'open-ils.circ.hold.clear_shelf.process',
3605     signature => {
3606         desc => q/
3607             1. Find all holds that have expired on the holds shelf
3608             2. Cancel the holds
3609             3. If a clear-shelf status is configured, put targeted copies into this status
3610             4. Divide copies into 3 groups: items to transit, items to reshelve, and items
3611                 that are needed for holds.  No subsequent action is taken on the holds
3612                 or items after grouping.
3613         /
3614     }
3615 );
3616
3617 sub clear_shelf_process {
3618     my($self, $client, $auth, $org_id, $match_copy, $chunk_size) = @_;
3619
3620     my $e = new_editor(authtoken=>$auth);
3621     $e->checkauth or return $e->die_event;
3622     my $cache = OpenSRF::Utils::Cache->new('global');
3623
3624     $org_id ||= $e->requestor->ws_ou;
3625     $e->allowed('UPDATE_HOLD', $org_id) or return $e->die_event;
3626
3627     my $copy_status = $U->ou_ancestor_setting_value($org_id, 'circ.holds.clear_shelf.copy_status');
3628
3629     my @hold_ids = $self->method_lookup(
3630         "open-ils.circ.captured_holds.id_list.expired_on_shelf.retrieve"
3631     )->run($auth, $org_id, $match_copy);
3632
3633     $e->xact_begin;
3634
3635     my @holds;
3636     my @canceled_holds; # newly canceled holds
3637     $chunk_size ||= 25; # chunked status updates
3638     $client->max_chunk_size($chunk_size) if (!$client->can('max_bundle_size') && $client->can('max_chunk_size'));
3639
3640     my $counter = 0;
3641     for my $hold_id (@hold_ids) {
3642
3643         $logger->info("Clear shelf processing hold $hold_id");
3644
3645         my $hold = $e->retrieve_action_hold_request([
3646             $hold_id, {
3647                 flesh => 1,
3648                 flesh_fields => {ahr => ['current_copy']}
3649             }
3650         ]);
3651
3652         if (!$hold->cancel_time) { # may be canceled but still on the holds shelf
3653             $hold->cancel_time('now');
3654             $hold->cancel_cause(2); # Hold Shelf expiration
3655             $e->update_action_hold_request($hold) or return $e->die_event;
3656             push(@canceled_holds, $hold_id);
3657         }
3658
3659         my $copy = $hold->current_copy;
3660
3661         if($copy_status or $copy_status == 0) {
3662             # if a clear-shelf copy status is defined, update the copy
3663             $copy->status($copy_status);
3664             $copy->edit_date('now');
3665             $copy->editor($e->requestor->id);
3666             $e->update_asset_copy($copy) or return $e->die_event;
3667         }
3668
3669         push(@holds, $hold);
3670         $client->respond({maximum => scalar(@holds), progress => $counter}) if ( (++$counter % $chunk_size) == 0);
3671     }
3672
3673     if ($e->commit) {
3674
3675         my %cache_data = (
3676             hold => [],
3677             transit => [],
3678             shelf => [],
3679             pl_changed => pickup_lib_changed_on_shelf_holds($e, $org_id, \@hold_ids)
3680         );
3681
3682         for my $hold (@holds) {
3683
3684             my $copy = $hold->current_copy;
3685             my ($alt_hold) = __PACKAGE__->find_nearest_permitted_hold($e, $copy, $e->requestor, 1);
3686
3687             if($alt_hold and !$match_copy) {
3688
3689                 push(@{$cache_data{hold}}, $hold->id); # copy is needed for a hold
3690
3691             } elsif($copy->circ_lib != $e->requestor->ws_ou) {
3692
3693                 push(@{$cache_data{transit}}, $hold->id); # copy needs to transit
3694
3695             } else {
3696
3697                 push(@{$cache_data{shelf}}, $hold->id); # copy needs to go back to the shelf
3698             }
3699         }
3700
3701         my $cache_key = md5_hex(time . $$ . rand());
3702         $logger->info("clear_shelf_cache: storing under $cache_key");
3703         $cache->put_cache($cache_key, \%cache_data, 7200); # TODO: 2 hours.  configurable?
3704
3705         # tell the client we're done
3706         $client->respond_complete({cache_key => $cache_key});
3707
3708         # ------------
3709         # fire off the hold cancelation trigger and wait for response so don't flood the service
3710
3711         # refetch the holds to pick up the caclulated cancel_time,
3712         # which may be needed by Action/Trigger
3713         $e->xact_begin;
3714         my $updated_holds = [];
3715         $updated_holds = $e->search_action_hold_request({id => \@canceled_holds}, {substream => 1}) if (@canceled_holds > 0);
3716         $e->rollback;
3717
3718         $U->create_events_for_hook(
3719             'hold_request.cancel.expire_holds_shelf',
3720             $_, $org_id, undef, undef, 1) for @$updated_holds;
3721
3722     } else {
3723         # tell the client we're done
3724         $client->respond_complete;
3725     }
3726 }
3727
3728 # returns IDs for holds that are on the holds shelf but 
3729 # have had their pickup_libs change while on the shelf.
3730 sub pickup_lib_changed_on_shelf_holds {
3731     my $e = shift;
3732     my $org_id = shift;
3733     my $ignore_holds = shift;
3734     $ignore_holds = [$ignore_holds] if !ref($ignore_holds);
3735
3736     my $query = {
3737         select => { alhr => ['id'] },
3738         from   => {
3739             alhr => {
3740                 acp => {
3741                     field => 'id',
3742                     fkey  => 'current_copy'
3743                 },
3744             }
3745         },
3746         where => {
3747             '+acp' => { status => OILS_COPY_STATUS_ON_HOLDS_SHELF },
3748             '+alhr' => {
3749                 capture_time     => { "!=" => undef },
3750                 fulfillment_time => undef,
3751                 current_shelf_lib => $org_id,
3752                 pickup_lib => {'!='  => {'+alhr' => 'current_shelf_lib'}}
3753             }
3754         }
3755     };
3756
3757     $query->{where}->{'+alhr'}->{id} =
3758         {'not in' => $ignore_holds} if @$ignore_holds;
3759
3760     my $hold_ids = $e->json_query($query);
3761     return [ map { $_->{id} } @$hold_ids ];
3762 }
3763
3764 __PACKAGE__->register_method(
3765     method    => 'usr_hold_summary',
3766     api_name  => 'open-ils.circ.holds.user_summary',
3767     signature => q/
3768         Returns a summary of holds statuses for a given user
3769     /
3770 );
3771
3772 sub usr_hold_summary {
3773     my($self, $conn, $auth, $user_id) = @_;
3774
3775     my $e = new_editor(authtoken=>$auth);
3776     $e->checkauth or return $e->event;
3777     $e->allowed('VIEW_HOLD') or return $e->event;
3778
3779     my $holds = $e->search_action_hold_request(
3780         {
3781             usr =>  $user_id ,
3782             fulfillment_time => undef,
3783             cancel_time      => undef,
3784         }
3785     );
3786
3787     my %summary = (1 => 0, 2 => 0, 3 => 0, 4 => 0);
3788     $summary{_hold_status($e, $_)} += 1 for @$holds;
3789     return \%summary;
3790 }
3791
3792
3793
3794 __PACKAGE__->register_method(
3795     method    => 'hold_has_copy_at',
3796     api_name  => 'open-ils.circ.hold.has_copy_at',
3797     signature => {
3798         desc   =>
3799                 'Returns the ID of the found copy and name of the shelving location if there is ' .
3800                 'an available copy at the specified org unit.  Returns empty hash otherwise.  '   .
3801                 'The anticipated use for this method is to determine whether an item is '         .
3802                 'available at the library where the user is placing the hold (or, alternatively, '.
3803                 'at the pickup library) to encourage bypassing the hold placement and just '      .
3804                 'checking out the item.' ,
3805         params => [
3806             { desc => 'Authentication Token', type => 'string' },
3807             { desc => 'Method Arguments.  Options include: hold_type, hold_target, org_unit.  '
3808                     . 'hold_type is the hold type code (T, V, C, M, ...).  '
3809                     . 'hold_target is the identifier of the hold target object.  '
3810                     . 'org_unit is org unit ID.',
3811               type => 'object'
3812             }
3813         ],
3814         return => {
3815             desc => q/Result hash like { "copy" : copy_id, "location" : location_name }, empty hash on misses, event on error./,
3816             type => 'object'
3817         }
3818     }
3819 );
3820
3821 sub hold_has_copy_at {
3822     my($self, $conn, $auth, $args) = @_;
3823
3824     my $e = new_editor(authtoken=>$auth);
3825     $e->checkauth or return $e->event;
3826
3827     my $hold_type   = $$args{hold_type};
3828     my $hold_target = $$args{hold_target};
3829     my $org_unit    = $$args{org_unit};
3830
3831     my $query = {
3832         select => {acp => ['id'], acpl => ['name']},
3833         from   => {
3834             acp => {
3835                 acpl => {
3836                     field => 'id',
3837                     filter => { holdable => 't', deleted => 'f' },
3838                     fkey => 'location'
3839                 },
3840                 ccs  => {field => 'id', filter => { holdable => 't'}, fkey => 'status'  }
3841             }
3842         },
3843         where => {'+acp' => { circulate => 't', deleted => 'f', holdable => 't', circ_lib => $org_unit, status => [0,7]}},
3844         limit => 1
3845     };
3846
3847     if($hold_type eq 'C' or $hold_type eq 'F' or $hold_type eq 'R') {
3848
3849         $query->{where}->{'+acp'}->{id} = $hold_target;
3850
3851     } elsif($hold_type eq 'V') {
3852
3853         $query->{where}->{'+acp'}->{call_number} = $hold_target;
3854
3855     } elsif($hold_type eq 'P') {
3856
3857         $query->{from}->{acp}->{acpm} = {
3858             field  => 'target_copy',
3859             fkey   => 'id',
3860             filter => {part => $hold_target},
3861         };
3862
3863     } elsif($hold_type eq 'I') {
3864
3865         $query->{from}->{acp}->{sitem} = {
3866             field  => 'unit',
3867             fkey   => 'id',
3868             filter => {issuance => $hold_target},
3869         };
3870
3871     } elsif($hold_type eq 'T') {
3872
3873         $query->{from}->{acp}->{acn} = {
3874             field  => 'id',
3875             fkey   => 'call_number',
3876             'join' => {
3877                 bre => {
3878                     field  => 'id',
3879                     filter => {id => $hold_target},
3880                     fkey   => 'record'
3881                 }
3882             }
3883         };
3884
3885     } else {
3886
3887         $query->{from}->{acp}->{acn} = {
3888             field => 'id',
3889             fkey  => 'call_number',
3890             join  => {
3891                 bre => {
3892                     field => 'id',
3893                     fkey  => 'record',
3894                     join  => {
3895                         mmrsm => {
3896                             field  => 'source',
3897                             fkey   => 'id',
3898                             filter => {metarecord => $hold_target},
3899                         }
3900                     }
3901                 }
3902             }
3903         };
3904     }
3905
3906     my $res = $e->json_query($query)->[0] or return {};
3907     return {copy => $res->{id}, location => $res->{name}} if $res;
3908 }
3909
3910
3911 # returns true if the user already has an item checked out
3912 # that could be used to fulfill the requested hold.
3913 sub hold_item_is_checked_out {
3914     my($e, $user_id, $hold_type, $hold_target) = @_;
3915
3916     my $query = {
3917         select => {acp => ['id']},
3918         from   => {acp => {}},
3919         where  => {
3920             '+acp' => {
3921                 id => {
3922                     in => { # copies for circs the user has checked out
3923                         select => {circ => ['target_copy']},
3924                         from   => 'circ',
3925                         where  => {
3926                             usr => $user_id,
3927                             checkin_time => undef,
3928                             '-or' => [
3929                                 {stop_fines => ["MAXFINES","LONGOVERDUE"]},
3930                                 {stop_fines => undef}
3931                             ],
3932                         }
3933                     }
3934                 }
3935             }
3936         },
3937         limit => 1
3938     };
3939
3940     if($hold_type eq 'C' || $hold_type eq 'R' || $hold_type eq 'F') {
3941
3942         $query->{where}->{'+acp'}->{id}->{in}->{where}->{'target_copy'} = $hold_target;
3943
3944     } elsif($hold_type eq 'V') {
3945
3946         $query->{where}->{'+acp'}->{call_number} = $hold_target;
3947
3948      } elsif($hold_type eq 'P') {
3949
3950         $query->{from}->{acp}->{acpm} = {
3951             field  => 'target_copy',
3952             fkey   => 'id',
3953             filter => {part => $hold_target},
3954         };
3955
3956      } elsif($hold_type eq 'I') {
3957
3958         $query->{from}->{acp}->{sitem} = {
3959             field  => 'unit',
3960             fkey   => 'id',
3961             filter => {issuance => $hold_target},
3962         };
3963
3964     } elsif($hold_type eq 'T') {
3965
3966         $query->{from}->{acp}->{acn} = {
3967             field  => 'id',
3968             fkey   => 'call_number',
3969             'join' => {
3970                 bre => {
3971                     field  => 'id',
3972                     filter => {id => $hold_target},
3973                     fkey   => 'record'
3974                 }
3975             }
3976         };
3977
3978     } else {
3979
3980         $query->{from}->{acp}->{acn} = {
3981             field => 'id',
3982             fkey => 'call_number',
3983             join => {
3984                 bre => {
3985                     field => 'id',
3986                     fkey => 'record',
3987                     join => {
3988                         mmrsm => {
3989                             field => 'source',
3990                             fkey => 'id',
3991                             filter => {metarecord => $hold_target},
3992                         }
3993                     }
3994                 }
3995             }
3996         };
3997     }
3998
3999     return $e->json_query($query)->[0];
4000 }
4001
4002 __PACKAGE__->register_method(
4003     method    => 'change_hold_title',
4004     api_name  => 'open-ils.circ.hold.change_title',
4005     signature => {
4006         desc => q/
4007             Updates all title level holds targeting the specified bibs to point a new bib./,
4008         params => [
4009             { desc => 'Authentication Token', type => 'string' },
4010             { desc => 'New Target Bib Id',    type => 'number' },
4011             { desc => 'Old Target Bib Ids',   type => 'array'  },
4012         ],
4013         return => { desc => '1 on success' }
4014     }
4015 );
4016
4017 __PACKAGE__->register_method(
4018     method    => 'change_hold_title_for_specific_holds',
4019     api_name  => 'open-ils.circ.hold.change_title.specific_holds',
4020     signature => {
4021         desc => q/
4022             Updates specified holds to target new bib./,
4023         params => [
4024             { desc => 'Authentication Token', type => 'string' },
4025             { desc => 'New Target Bib Id',    type => 'number' },
4026             { desc => 'Holds Ids for holds to update',   type => 'array'  },
4027         ],
4028         return => { desc => '1 on success' }
4029     }
4030 );
4031
4032
4033 sub change_hold_title {
4034     my( $self, $client, $auth, $new_bib_id, $bib_ids ) = @_;
4035
4036     my $e = new_editor(authtoken=>$auth, xact=>1);
4037     return $e->die_event unless $e->checkauth;
4038
4039     my $holds = $e->search_action_hold_request(
4040         [
4041             {
4042                 capture_time     => undef,
4043                 cancel_time      => undef,
4044                 fulfillment_time => undef,
4045                 hold_type        => 'T',
4046                 target           => $bib_ids
4047             },
4048             {
4049                 flesh        => 1,
4050                 flesh_fields => { ahr => ['usr'] }
4051             }
4052         ],
4053         { substream => 1 }
4054     );
4055
4056     for my $hold (@$holds) {
4057         $e->allowed('UPDATE_HOLD', $hold->usr->home_ou) or return $e->die_event;
4058         $logger->info("Changing hold " . $hold->id . " target from " . $hold->target . " to $new_bib_id in title hold target change");
4059         $hold->target( $new_bib_id );
4060         $e->update_action_hold_request($hold) or return $e->die_event;
4061     }
4062
4063     $e->commit;
4064
4065     _reset_hold($self, $e->requestor, $_) for @$holds;
4066
4067     return 1;
4068 }
4069
4070 sub change_hold_title_for_specific_holds {
4071     my( $self, $client, $auth, $new_bib_id, $hold_ids ) = @_;
4072
4073     my $e = new_editor(authtoken=>$auth, xact=>1);
4074     return $e->die_event unless $e->checkauth;
4075
4076     my $holds = $e->search_action_hold_request(
4077         [
4078             {
4079                 capture_time     => undef,
4080                 cancel_time      => undef,
4081                 fulfillment_time => undef,
4082                 hold_type        => 'T',
4083                 id               => $hold_ids
4084             },
4085             {
4086                 flesh        => 1,
4087                 flesh_fields => { ahr => ['usr'] }
4088             }
4089         ],
4090         { substream => 1 }
4091     );
4092
4093     for my $hold (@$holds) {
4094         $e->allowed('UPDATE_HOLD', $hold->usr->home_ou) or return $e->die_event;
4095         $logger->info("Changing hold " . $hold->id . " target from " . $hold->target . " to $new_bib_id in title hold target change");
4096         $hold->target( $new_bib_id );
4097         $e->update_action_hold_request($hold) or return $e->die_event;
4098     }
4099
4100     $e->commit;
4101
4102     _reset_hold($self, $e->requestor, $_) for @$holds;
4103
4104     return 1;
4105 }
4106
4107 __PACKAGE__->register_method(
4108     method    => 'rec_hold_count',
4109     api_name  => 'open-ils.circ.bre.holds.count',
4110     signature => {
4111         desc => q/Returns the total number of holds that target the
4112             selected bib record or its associated copies and call_numbers/,
4113         params => [
4114             { desc => 'Bib ID', type => 'number' },
4115             { desc => q/Optional arguments.  Supported arguments include:
4116                 "pickup_lib_descendant" -> limit holds to those whose pickup
4117                 library is equal to or is a child of the provided org unit/,
4118                 type => 'object'
4119             }
4120         ],
4121         return => {desc => 'Hold count', type => 'number'}
4122     }
4123 );
4124
4125 __PACKAGE__->register_method(
4126     method    => 'rec_hold_count',
4127     api_name  => 'open-ils.circ.mmr.holds.count',
4128     signature => {
4129         desc => q/Returns the total number of holds that target the
4130             selected metarecord or its associated copies, call_numbers, and bib records/,
4131         params => [
4132             { desc => 'Metarecord ID', type => 'number' },
4133         ],
4134         return => {desc => 'Hold count', type => 'number'}
4135     }
4136 );
4137
4138 # XXX Need to add type I holds to these counts
4139 sub rec_hold_count {
4140     my($self, $conn, $target_id, $args) = @_;
4141     $args ||= {};
4142
4143     my $mmr_join = {
4144         mmrsm => {
4145             field => 'source',
4146             fkey => 'id',
4147             filter => {metarecord => $target_id}
4148         }
4149     };
4150
4151     my $bre_join = {
4152         bre => {
4153             field => 'id',
4154             filter => { id => $target_id },
4155             fkey => 'record'
4156         }
4157     };
4158
4159     if($self->api_name =~ /mmr/) {
4160         delete $bre_join->{bre}->{filter};
4161         $bre_join->{bre}->{join} = $mmr_join;
4162     }
4163
4164     my $cn_join = {
4165         acn => {
4166             field => 'id',
4167             fkey => 'call_number',
4168             join => $bre_join
4169         }
4170     };
4171
4172     my $query = {
4173         select => {ahr => [{column => 'id', transform => 'count', alias => 'count'}]},
4174         from => 'ahr',
4175         where => {
4176             '+ahr' => {
4177                 cancel_time => undef,
4178                 fulfillment_time => undef,
4179                 '-or' => [
4180                     {
4181                         '-and' => {
4182                             hold_type => [qw/C F R/],
4183                             target => {
4184                                 in => {
4185                                     select => {acp => ['id']},
4186                                     from => { acp => $cn_join }
4187                                 }
4188                             }
4189                         }
4190                     },
4191                     {
4192                         '-and' => {
4193                             hold_type => 'V',
4194                             target => {
4195                                 in => {
4196                                     select => {acn => ['id']},
4197                                     from => {acn => $bre_join}
4198                                 }
4199                             }
4200                         }
4201                     },
4202                     {
4203                         '-and' => {
4204                             hold_type => 'P',
4205                             target => {
4206                                 in => {
4207                                     select => {bmp => ['id']},
4208                                     from => {bmp => $bre_join}
4209                                 }
4210                             }
4211                         }
4212                     },
4213                     {
4214                         '-and' => {
4215                             hold_type => 'T',
4216                             target => $target_id
4217                         }
4218                     }
4219                 ]
4220             }
4221         }
4222     };
4223
4224     if($self->api_name =~ /mmr/) {
4225         $query->{where}->{'+ahr'}->{'-or'}->[3] = {
4226             '-and' => {
4227                 hold_type => 'T',
4228                 target => {
4229                     in => {
4230                         select => {bre => ['id']},
4231                         from => {bre => $mmr_join}
4232                     }
4233                 }
4234             }
4235         };
4236
4237         $query->{where}->{'+ahr'}->{'-or'}->[4] = {
4238             '-and' => {
4239                 hold_type => 'M',
4240                 target => $target_id
4241             }
4242         };
4243     }
4244
4245
4246     if (my $pld = $args->{pickup_lib_descendant}) {
4247
4248         my $top_ou = new_editor()->search_actor_org_unit(
4249             {parent_ou => undef}
4250         )->[0]; # XXX Assumes single root node. Not alone in this...
4251
4252         $query->{where}->{'+ahr'}->{pickup_lib} = {
4253             in => {
4254                 select  => {aou => [{ 
4255                     column => 'id', 
4256                     transform => 'actor.org_unit_descendants', 
4257                     result_field => 'id' 
4258                 }]},
4259                 from    => 'aou',
4260                 where   => {id => $pld}
4261             }
4262         } if ($pld != $top_ou->id);
4263     }
4264
4265
4266     return new_editor()->json_query($query)->[0]->{count};
4267 }
4268
4269 # A helper function to calculate a hold's expiration time at a given
4270 # org_unit. Takes the org_unit as an argument and returns either the
4271 # hold expire time as an ISO8601 string or undef if there is no hold
4272 # expiration interval set for the subject ou.
4273 sub calculate_expire_time
4274 {
4275     my $ou = shift;
4276     my $interval = $U->ou_ancestor_setting_value($ou, OILS_SETTING_HOLD_EXPIRE);
4277     if($interval) {
4278         my $date = DateTime->now->add(seconds => OpenSRF::Utils::interval_to_seconds($interval));
4279         return $U->epoch2ISO8601($date->epoch);
4280     }
4281     return undef;
4282 }
4283
4284
4285 __PACKAGE__->register_method(
4286     method    => 'mr_hold_filter_attrs',
4287     api_name  => 'open-ils.circ.mmr.holds.filters',
4288     authoritative => 1,
4289     stream => 1,
4290     signature => {
4291         desc => q/
4292             Returns the set of available formats and languages for the
4293             constituent records of the provided metarcord.
4294             If an array of hold IDs is also provided, information about
4295             each is returned as well.  This information includes:
4296             1. a slightly easier to read version of holdable_formats
4297             2. attributes describing the set of format icons included
4298                in the set of desired, constituent records.
4299         /,
4300         params => [
4301             {desc => 'Metarecord ID', type => 'number'},
4302             {desc => 'Context Org ID', type => 'number'},
4303             {desc => 'Hold ID List', type => 'array'},
4304         ],
4305         return => {
4306             desc => q/
4307                 Stream of objects.  The first will have a 'metarecord' key
4308                 containing non-hold-specific metarecord information, subsequent
4309                 responses will contain a 'hold' key containing hold-specific
4310                 information
4311             /, 
4312             type => 'object'
4313         }
4314     }
4315 );
4316
4317 sub mr_hold_filter_attrs { 
4318     my ($self, $client, $mr_id, $org_id, $hold_ids) = @_;
4319     my $e = new_editor();
4320
4321     # by default, return MR / hold attributes for all constituent
4322     # records with holdable copies.  If there is a hard boundary,
4323     # though, limit to records with copies within the boundary,
4324     # since anything outside the boundary can never be held.
4325     my $org_depth = 0;
4326     if ($org_id) {
4327         $org_depth = $U->ou_ancestor_setting_value(
4328             $org_id, OILS_SETTING_HOLD_HARD_BOUNDARY) || 0;
4329     }
4330
4331     # get all org-scoped records w/ holdable copies for this metarecord
4332     my ($bre_ids) = $self->method_lookup(
4333         'open-ils.circ.holds.metarecord.filtered_records')->run(
4334             $mr_id, undef, $org_id, $org_depth);
4335
4336     my $item_lang_attr = 'item_lang'; # configurable?
4337     my $format_attr = $e->retrieve_config_global_flag(
4338         'opac.metarecord.holds.format_attr')->value;
4339
4340     # helper sub for fetching ccvms for a batch of record IDs
4341     sub get_batch_ccvms {
4342         my ($e, $attr, $bre_ids) = @_;
4343         return [] unless $bre_ids and @$bre_ids;
4344         my $vals = $e->search_metabib_record_attr_flat({
4345             attr => $attr,
4346             id => $bre_ids
4347         });
4348         return [] unless @$vals;
4349         return $e->search_config_coded_value_map({
4350             ctype => $attr,
4351             code => [map {$_->value} @$vals]
4352         });
4353     }
4354
4355     my $langs = get_batch_ccvms($e, $item_lang_attr, $bre_ids);
4356     my $formats = get_batch_ccvms($e, $format_attr, $bre_ids);
4357
4358     $client->respond({
4359         metarecord => {
4360             id => $mr_id,
4361             formats => $formats,
4362             langs => $langs
4363         }
4364     });
4365
4366     return unless $hold_ids;
4367     my $icon_attr = $e->retrieve_config_global_flag('opac.icon_attr');
4368     $icon_attr = $icon_attr ? $icon_attr->value : '';
4369
4370     for my $hold_id (@$hold_ids) {
4371         my $hold = $e->retrieve_action_hold_request($hold_id) 
4372             or return $e->event;
4373
4374         next unless $hold->hold_type eq 'M';
4375
4376         my $resp = {
4377             hold => {
4378                 id => $hold_id,
4379                 formats => [],
4380                 langs => []
4381             }
4382         };
4383
4384         # collect the ccvm's for the selected formats / language
4385         # (i.e. the holdable formats) on the MR.
4386         # this assumes a two-key structure for format / language,
4387         # though no assumption is made about the keys themselves.
4388         my $hformats = OpenSRF::Utils::JSON->JSON2perl($hold->holdable_formats);
4389         my $lang_vals = [];
4390         my $format_vals = [];
4391         for my $val (values %$hformats) {
4392             # val is either a single ccvm or an array of them
4393             $val = [$val] unless ref $val eq 'ARRAY';
4394             for my $node (@$val) {
4395                 push (@$lang_vals, $node->{_val})   
4396                     if $node->{_attr} eq $item_lang_attr; 
4397                 push (@$format_vals, $node->{_val})   
4398                     if $node->{_attr} eq $format_attr;
4399             }
4400         }
4401
4402         # fetch the ccvm's for consistency with the {metarecord} blob
4403         $resp->{hold}{formats} = $e->search_config_coded_value_map({
4404             ctype => $format_attr, code => $format_vals});
4405         $resp->{hold}{langs} = $e->search_config_coded_value_map({
4406             ctype => $item_lang_attr, code => $lang_vals});
4407
4408         # find all of the bib records within this metarcord whose 
4409         # format / language match the holdable formats on the hold
4410         my ($bre_ids) = $self->method_lookup(
4411             'open-ils.circ.holds.metarecord.filtered_records')->run(
4412                 $hold->target, $hold->holdable_formats, 
4413                 $hold->selection_ou, $hold->selection_depth);
4414
4415         # now find all of the 'icon' attributes for the records
4416         $resp->{hold}{icons} = get_batch_ccvms($e, $icon_attr, $bre_ids);
4417         $client->respond($resp);
4418     }
4419
4420     return;
4421 }
4422
4423 __PACKAGE__->register_method(
4424     method        => "copy_has_holds_count",
4425     api_name      => "open-ils.circ.copy.has_holds_count",
4426     authoritative => 1,
4427     signature     => {
4428         desc => q/
4429             Returns the number of holds a paticular copy has
4430         /,
4431         params => [
4432             { desc => 'Authentication Token', type => 'string'},
4433             { desc => 'Copy ID', type => 'number'}
4434         ],
4435         return => {
4436             desc => q/
4437                 Simple count value
4438             /,
4439             type => 'number'
4440         }
4441     }
4442 );
4443
4444 sub copy_has_holds_count {
4445     my( $self, $conn, $auth, $copyid ) = @_;
4446     my $e = new_editor(authtoken=>$auth);
4447     return $e->event unless $e->checkauth;
4448
4449     if( $copyid && $copyid > 0 ) {
4450         my $meth = 'retrieve_action_has_holds_count';
4451         my $data = $e->$meth($copyid);
4452         if($data){
4453                 return $data->count();
4454         }
4455     }
4456     return 0;
4457 }
4458
4459 1;