]> git.evergreen-ils.org Git - working/Evergreen.git/blob - Open-ILS/src/perlmods/lib/OpenILS/Application/Circ/Holds.pm
LP#1596595 open-ils.hold-targeter service
[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) {
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},
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},{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, 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                             dest_recv_time => {'!=' => undef},
1297                          })->[0];
1298         my $start_time = ($transit) ? $transit->dest_recv_time : $hold->capture_time;
1299         $start_time    = DateTime::Format::ISO8601->new->parse_datetime(cleanse_ISO8601($start_time));
1300         my $end_time   = $start_time->add(seconds => OpenSRF::Utils::interval_to_seconds($hs_wait_interval));
1301
1302         return 5 if $end_time > DateTime->now;
1303         return 4;
1304     }
1305
1306     return -1;  # error
1307 }
1308
1309
1310
1311 __PACKAGE__->register_method(
1312     method    => "retrieve_hold_queue_stats",
1313     api_name  => "open-ils.circ.hold.queue_stats.retrieve",
1314     signature => {
1315         desc   => 'Returns summary data about the state of a hold',
1316         params => [
1317             { desc => 'Authentication token',  type => 'string'},
1318             { desc => 'Hold ID', type => 'number'},
1319         ],
1320         return => {
1321             desc => q/Summary object with keys:
1322                 total_holds : total holds in queue
1323                 queue_position : current queue position
1324                 potential_copies : number of potential copies for this hold
1325                 estimated_wait : estimated wait time in days
1326                 status : hold status
1327                      -1 => error or unexpected state,
1328                      1 => 'waiting for copy to become available',
1329                      2 => 'waiting for copy capture',
1330                      3 => 'in transit',
1331                      4 => 'arrived',
1332                      5 => 'hold-shelf-delay'
1333             /,
1334             type => 'object'
1335         }
1336     }
1337 );
1338
1339 sub retrieve_hold_queue_stats {
1340     my($self, $conn, $auth, $hold_id) = @_;
1341     my $e = new_editor(authtoken => $auth);
1342     return $e->event unless $e->checkauth;
1343     my $hold = $e->retrieve_action_hold_request($hold_id) or return $e->event;
1344     if($e->requestor->id != $hold->usr) {
1345         return $e->event unless $e->allowed('VIEW_HOLD');
1346     }
1347     return retrieve_hold_queue_status_impl($e, $hold);
1348 }
1349
1350 sub retrieve_hold_queue_status_impl {
1351     my $e = shift;
1352     my $hold = shift;
1353
1354     # The holds queue is defined as the distinct set of holds that share at
1355     # least one potential copy with the context hold, plus any holds that
1356     # share the same hold type and target.  The latter part exists to
1357     # accomodate holds that currently have no potential copies
1358     my $q_holds = $e->json_query({
1359
1360         # fetch cut_in_line and request_time since they're in the order_by
1361         # and we're asking for distinct values
1362         select => {ahr => ['id', 'cut_in_line', 'request_time']},
1363         from   => 'ahr',
1364         where => {
1365             id => { in => {
1366                 select => { ahcm => ['hold'] },
1367                 from   => {
1368                     'ahcm' => {
1369                         'ahcm2' => {
1370                             'class' => 'ahcm',
1371                             'field' => 'target_copy',
1372                             'fkey'  => 'target_copy'
1373                         }
1374                     }
1375                 },
1376                 where => { '+ahcm2' => { hold => $hold->id } },
1377                 distinct => 1
1378             }}
1379         },
1380         order_by => [
1381             {
1382                 "class" => "ahr",
1383                 "field" => "cut_in_line",
1384                 "transform" => "coalesce",
1385                 "params" => [ 0 ],
1386                 "direction" => "desc"
1387             },
1388             { "class" => "ahr", "field" => "request_time" }
1389         ],
1390         distinct => 1
1391     });
1392
1393     if (!@$q_holds) { # none? maybe we don't have a map ...
1394         $q_holds = $e->json_query({
1395             select => {ahr => ['id', 'cut_in_line', 'request_time']},
1396             from   => 'ahr',
1397             order_by => [
1398                 {
1399                     "class" => "ahr",
1400                     "field" => "cut_in_line",
1401                     "transform" => "coalesce",
1402                     "params" => [ 0 ],
1403                     "direction" => "desc"
1404                 },
1405                 { "class" => "ahr", "field" => "request_time" }
1406             ],
1407             where    => {
1408                 hold_type => $hold->hold_type,
1409                 target    => $hold->target,
1410                 capture_time => undef,
1411                 cancel_time => undef,
1412                 '-or' => [
1413                     {expire_time => undef },
1414                     {expire_time => {'>' => 'now'}}
1415                 ]
1416            }
1417         });
1418     }
1419
1420
1421     my $qpos = 1;
1422     for my $h (@$q_holds) {
1423         last if $h->{id} == $hold->id;
1424         $qpos++;
1425     }
1426
1427     my $hold_data = $e->json_query({
1428         select => {
1429             acp => [ {column => 'id', transform => 'count', aggregate => 1, alias => 'count'} ],
1430             ccm => [ {column =>'avg_wait_time'} ]
1431         },
1432         from => {
1433             ahcm => {
1434                 acp => {
1435                     join => {
1436                         ccm => {type => 'left'}
1437                     }
1438                 }
1439             }
1440         },
1441         where => {'+ahcm' => {hold => $hold->id} }
1442     });
1443
1444     my $user_org = $e->json_query({select => {au => ['home_ou']}, from => 'au', where => {id => $hold->usr}})->[0]->{home_ou};
1445
1446     my $default_wait = $U->ou_ancestor_setting_value($user_org, OILS_SETTING_HOLD_ESIMATE_WAIT_INTERVAL);
1447     my $min_wait = $U->ou_ancestor_setting_value($user_org, 'circ.holds.min_estimated_wait_interval');
1448     $min_wait = OpenSRF::Utils::interval_to_seconds($min_wait || '0 seconds');
1449     $default_wait ||= '0 seconds';
1450
1451     # Estimated wait time is the average wait time across the set
1452     # of potential copies, divided by the number of potential copies
1453     # times the queue position.
1454
1455     my $combined_secs = 0;
1456     my $num_potentials = 0;
1457
1458     for my $wait_data (@$hold_data) {
1459         my $count += $wait_data->{count};
1460         $combined_secs += $count *
1461             OpenSRF::Utils::interval_to_seconds($wait_data->{avg_wait_time} || $default_wait);
1462         $num_potentials += $count;
1463     }
1464
1465     my $estimated_wait = -1;
1466
1467     if($num_potentials) {
1468         my $avg_wait = $combined_secs / $num_potentials;
1469         $estimated_wait = $qpos * ($avg_wait / $num_potentials);
1470         $estimated_wait = $min_wait if $estimated_wait < $min_wait and $estimated_wait != -1;
1471     }
1472
1473     return {
1474         total_holds      => scalar(@$q_holds),
1475         queue_position   => $qpos,
1476         potential_copies => $num_potentials,
1477         status           => _hold_status( $e, $hold ),
1478         estimated_wait   => int($estimated_wait)
1479     };
1480 }
1481
1482
1483 sub fetch_open_hold_by_current_copy {
1484     my $class = shift;
1485     my $copyid = shift;
1486     my $hold = $apputils->simplereq(
1487         'open-ils.cstore',
1488         'open-ils.cstore.direct.action.hold_request.search.atomic',
1489         { current_copy =>  $copyid , cancel_time => undef, fulfillment_time => undef });
1490     return $hold->[0] if ref($hold);
1491     return undef;
1492 }
1493
1494 sub fetch_related_holds {
1495     my $class = shift;
1496     my $copyid = shift;
1497     return $apputils->simplereq(
1498         'open-ils.cstore',
1499         'open-ils.cstore.direct.action.hold_request.search.atomic',
1500         { current_copy =>  $copyid , cancel_time => undef, fulfillment_time => undef });
1501 }
1502
1503
1504 __PACKAGE__->register_method(
1505     method    => "hold_pull_list",
1506     api_name  => "open-ils.circ.hold_pull_list.retrieve",
1507     signature => {
1508         desc   => 'Returns (reference to) a list of holds that need to be "pulled" by a given location. ' .
1509                   'The location is determined by the login session.',
1510         params => [
1511             { desc => 'Limit (optional)',  type => 'number'},
1512             { desc => 'Offset (optional)', type => 'number'},
1513         ],
1514         return => {
1515             desc => 'reference to a list of holds, or event on failure',
1516         }
1517     }
1518 );
1519
1520 __PACKAGE__->register_method(
1521     method    => "hold_pull_list",
1522     api_name  => "open-ils.circ.hold_pull_list.id_list.retrieve",
1523     signature => {
1524         desc   => 'Returns (reference to) a list of holds IDs that need to be "pulled" by a given location. ' .
1525                   'The location is determined by the login session.',
1526         params => [
1527             { desc => 'Limit (optional)',  type => 'number'},
1528             { desc => 'Offset (optional)', type => 'number'},
1529         ],
1530         return => {
1531             desc => 'reference to a list of holds, or event on failure',
1532         }
1533     }
1534 );
1535
1536 __PACKAGE__->register_method(
1537     method    => "hold_pull_list",
1538     api_name  => "open-ils.circ.hold_pull_list.retrieve.count",
1539     signature => {
1540         desc   => 'Returns a count of holds that need to be "pulled" by a given location. ' .
1541                   'The location is determined by the login session.',
1542         params => [
1543             { desc => 'Limit (optional)',  type => 'number'},
1544             { desc => 'Offset (optional)', type => 'number'},
1545         ],
1546         return => {
1547             desc => 'Holds count (integer), or event on failure',
1548             # type => 'number'
1549         }
1550     }
1551 );
1552
1553 __PACKAGE__->register_method(
1554     method    => "hold_pull_list",
1555     stream => 1,
1556     # TODO: tag with api_level 2 once fully supported
1557     api_name  => "open-ils.circ.hold_pull_list.fleshed.stream",
1558     signature => {
1559         desc   => q/Returns a stream of fleshed holds  that need to be 
1560                     "pulled" by a given location.  The location is 
1561                     determined by the login session.  
1562                     This API calls always run in authoritative mode./,
1563         params => [
1564             { desc => 'Limit (optional)',  type => 'number'},
1565             { desc => 'Offset (optional)', type => 'number'},
1566         ],
1567         return => {
1568             desc => 'Stream of holds holds, or event on failure',
1569         }
1570     }
1571 );
1572
1573 sub hold_pull_list {
1574     my( $self, $conn, $authtoken, $limit, $offset ) = @_;
1575     my( $reqr, $evt ) = $U->checkses($authtoken);
1576     return $evt if $evt;
1577
1578     my $org = $reqr->ws_ou || $reqr->home_ou;
1579     # the perm locaiton shouldn't really matter here since holds
1580     # will exist all over and VIEW_HOLDS should be universal
1581     $evt = $U->check_perms($reqr->id, $org, 'VIEW_HOLD');
1582     return $evt if $evt;
1583
1584     if($self->api_name =~ /count/) {
1585
1586         my $count = $U->storagereq(
1587             'open-ils.storage.direct.action.hold_request.pull_list.current_copy_circ_lib.status_filtered.count',
1588             $org, $limit, $offset );
1589
1590         $logger->info("Grabbing pull list for org unit $org with $count items");
1591         return $count;
1592
1593     } elsif( $self->api_name =~ /id_list/ ) {
1594         $U->storagereq(
1595             'open-ils.storage.direct.action.hold_request.pull_list.id_list.current_copy_circ_lib.status_filtered.atomic',
1596             $org, $limit, $offset );
1597
1598     } elsif ($self->api_name =~ /fleshed/) {
1599
1600         my $ids = $U->storagereq(
1601             'open-ils.storage.direct.action.hold_request.pull_list.id_list.current_copy_circ_lib.status_filtered.atomic',
1602             $org, $limit, $offset );
1603
1604         my $e = new_editor(xact => 1, requestor => $reqr);
1605         $conn->respond(uber_hold_impl($e, $_, {flesh_acpl => 1})) for @$ids;
1606         $e->rollback;
1607         $conn->respond_complete;
1608         return;
1609
1610     } else {
1611         $U->storagereq(
1612             'open-ils.storage.direct.action.hold_request.pull_list.search.current_copy_circ_lib.status_filtered.atomic',
1613             $org, $limit, $offset );
1614     }
1615 }
1616
1617 __PACKAGE__->register_method(
1618     method    => "print_hold_pull_list",
1619     api_name  => "open-ils.circ.hold_pull_list.print",
1620     signature => {
1621         desc   => 'Returns an HTML-formatted holds pull list',
1622         params => [
1623             { desc => 'Authtoken', type => 'string'},
1624             { desc => 'Org unit ID.  Optional, defaults to workstation org unit', type => 'number'},
1625         ],
1626         return => {
1627             desc => 'HTML string',
1628             type => 'string'
1629         }
1630     }
1631 );
1632
1633 sub print_hold_pull_list {
1634     my($self, $client, $auth, $org_id) = @_;
1635
1636     my $e = new_editor(authtoken=>$auth);
1637     return $e->event unless $e->checkauth;
1638
1639     $org_id = (defined $org_id) ? $org_id : $e->requestor->ws_ou;
1640     return $e->event unless $e->allowed('VIEW_HOLD', $org_id);
1641
1642     my $hold_ids = $U->storagereq(
1643         'open-ils.storage.direct.action.hold_request.pull_list.id_list.current_copy_circ_lib.status_filtered.atomic',
1644         $org_id, 10000);
1645
1646     return undef unless @$hold_ids;
1647
1648     $client->status(new OpenSRF::DomainObject::oilsContinueStatus);
1649
1650     # Holds will /NOT/ be in order after this ...
1651     my $holds = $e->search_action_hold_request({id => $hold_ids}, {substream => 1});
1652     $client->status(new OpenSRF::DomainObject::oilsContinueStatus);
1653
1654     # ... so we must resort.
1655     my $hold_map = +{map { $_->id => $_ } @$holds};
1656     my $sorted_holds = [];
1657     push @$sorted_holds, $hold_map->{$_} foreach @$hold_ids;
1658
1659     return $U->fire_object_event(
1660         undef, "ahr.format.pull_list", $sorted_holds,
1661         $org_id, undef, undef, $client
1662     );
1663
1664 }
1665
1666 __PACKAGE__->register_method(
1667     method    => "print_hold_pull_list_stream",
1668     stream   => 1,
1669     api_name  => "open-ils.circ.hold_pull_list.print.stream",
1670     signature => {
1671         desc   => 'Returns a stream of fleshed holds',
1672         params => [
1673             { desc => 'Authtoken', type => 'string'},
1674             { 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)',
1675               type => 'object'
1676             },
1677         ],
1678         return => {
1679             desc => 'A stream of fleshed holds',
1680             type => 'object'
1681         }
1682     }
1683 );
1684
1685 sub print_hold_pull_list_stream {
1686     my($self, $client, $auth, $params) = @_;
1687
1688     my $e = new_editor(authtoken=>$auth);
1689     return $e->die_event unless $e->checkauth;
1690
1691     delete($$params{org_id}) unless (int($$params{org_id}));
1692     delete($$params{limit}) unless (int($$params{limit}));
1693     delete($$params{offset}) unless (int($$params{offset}));
1694     delete($$params{chunk_size}) unless (int($$params{chunk_size}));
1695     delete($$params{chunk_size}) if  ($$params{chunk_size} && $$params{chunk_size} > 50); # keep the size reasonable
1696     $$params{chunk_size} ||= 10;
1697     $client->max_chunk_size($$params{chunk_size}) if ($client->can('max_chunk_size'));
1698
1699     $$params{org_id} = (defined $$params{org_id}) ? $$params{org_id}: $e->requestor->ws_ou;
1700     return $e->die_event unless $e->allowed('VIEW_HOLD', $$params{org_id });
1701
1702     my $sort = [];
1703     if ($$params{sort} && @{ $$params{sort} }) {
1704         for my $s (@{ $$params{sort} }) {
1705             if ($s eq 'acplo.position') {
1706                 push @$sort, {
1707                     "class" => "acplo", "field" => "position",
1708                     "transform" => "coalesce", "params" => [999]
1709                 };
1710             } elsif ($s eq 'prefix') {
1711                 push @$sort, {"class" => "acnp", "field" => "label_sortkey"};
1712             } elsif ($s eq 'call_number') {
1713                 push @$sort, {"class" => "acn", "field" => "label_sortkey"};
1714             } elsif ($s eq 'suffix') {
1715                 push @$sort, {"class" => "acns", "field" => "label_sortkey"};
1716             } elsif ($s eq 'request_time') {
1717                 push @$sort, {"class" => "ahr", "field" => "request_time"};
1718             }
1719         }
1720     } else {
1721         push @$sort, {"class" => "ahr", "field" => "request_time"};
1722     }
1723
1724     my $holds_ids = $e->json_query(
1725         {
1726             "select" => {"ahr" => ["id"]},
1727             "from" => {
1728                 "ahr" => {
1729                     "acp" => {
1730                         "field" => "id",
1731                         "fkey" => "current_copy",
1732                         "filter" => {
1733                             "circ_lib" => $$params{org_id}, "status" => [0,7]
1734                         },
1735                         "join" => {
1736                             "acn" => {
1737                                 "field" => "id",
1738                                 "fkey" => "call_number",
1739                                 "join" => {
1740                                     "acnp" => {
1741                                         "field" => "id",
1742                                         "fkey" => "prefix"
1743                                     },
1744                                     "acns" => {
1745                                         "field" => "id",
1746                                         "fkey" => "suffix"
1747                                     }
1748                                 }
1749                             },
1750                             "acplo" => {
1751                                 "field" => "org",
1752                                 "fkey" => "circ_lib",
1753                                 "type" => "left",
1754                                 "filter" => {
1755                                     "location" => {"=" => {"+acp" => "location"}}
1756                                 }
1757                             }
1758                         }
1759                     }
1760                 }
1761             },
1762             "where" => {
1763                 "+ahr" => {
1764                     "capture_time" => undef,
1765                     "cancel_time" => undef,
1766                     "-or" => [
1767                         {"expire_time" => undef },
1768                         {"expire_time" => {">" => "now"}}
1769                     ]
1770                 }
1771             },
1772             (@$sort ? (order_by => $sort) : ()),
1773             ($$params{limit} ? (limit => $$params{limit}) : ()),
1774             ($$params{offset} ? (offset => $$params{offset}) : ())
1775         }, {"substream" => 1}
1776     ) or return $e->die_event;
1777
1778     $logger->info("about to stream back " . scalar(@$holds_ids) . " holds");
1779
1780     my @chunk;
1781     for my $hid (@$holds_ids) {
1782         push @chunk, $e->retrieve_action_hold_request([
1783             $hid->{"id"}, {
1784                 "flesh" => 3,
1785                 "flesh_fields" => {
1786                     "ahr" => ["usr", "current_copy"],
1787                     "au"  => ["card"],
1788                     "acp" => ["location", "call_number", "parts"],
1789                     "acn" => ["record","prefix","suffix"]
1790                 }
1791             }
1792         ]);
1793
1794         if (@chunk >= $$params{chunk_size}) {
1795             $client->respond( \@chunk );
1796             @chunk = ();
1797         }
1798     }
1799     $client->respond_complete( \@chunk ) if (@chunk);
1800     $e->disconnect;
1801     return undef;
1802 }
1803
1804
1805
1806 __PACKAGE__->register_method(
1807     method        => 'fetch_hold_notify',
1808     api_name      => 'open-ils.circ.hold_notification.retrieve_by_hold',
1809     authoritative => 1,
1810     signature     => q/
1811 Returns a list of hold notification objects based on hold id.
1812 @param authtoken The loggin session key
1813 @param holdid The id of the hold whose notifications we want to retrieve
1814 @return An array of hold notification objects, event on error.
1815 /
1816 );
1817
1818 sub fetch_hold_notify {
1819     my( $self, $conn, $authtoken, $holdid ) = @_;
1820     my( $requestor, $evt ) = $U->checkses($authtoken);
1821     return $evt if $evt;
1822     my ($hold, $patron);
1823     ($hold, $evt) = $U->fetch_hold($holdid);
1824     return $evt if $evt;
1825     ($patron, $evt) = $U->fetch_user($hold->usr);
1826     return $evt if $evt;
1827
1828     $evt = $U->check_perms($requestor->id, $patron->home_ou, 'VIEW_HOLD_NOTIFICATION');
1829     return $evt if $evt;
1830
1831     $logger->info("User ".$requestor->id." fetching hold notifications for hold $holdid");
1832     return $U->cstorereq(
1833         'open-ils.cstore.direct.action.hold_notification.search.atomic', {hold => $holdid} );
1834 }
1835
1836
1837 __PACKAGE__->register_method(
1838     method    => 'create_hold_notify',
1839     api_name  => 'open-ils.circ.hold_notification.create',
1840     signature => q/
1841 Creates a new hold notification object
1842 @param authtoken The login session key
1843 @param notification The hold notification object to create
1844 @return ID of the new object on success, Event on error
1845 /
1846 );
1847
1848 sub create_hold_notify {
1849    my( $self, $conn, $auth, $note ) = @_;
1850    my $e = new_editor(authtoken=>$auth, xact=>1);
1851    return $e->die_event unless $e->checkauth;
1852
1853    my $hold = $e->retrieve_action_hold_request($note->hold)
1854       or return $e->die_event;
1855    my $patron = $e->retrieve_actor_user($hold->usr)
1856       or return $e->die_event;
1857
1858    return $e->die_event unless
1859       $e->allowed('CREATE_HOLD_NOTIFICATION', $patron->home_ou);
1860
1861    $note->notify_staff($e->requestor->id);
1862    $e->create_action_hold_notification($note) or return $e->die_event;
1863    $e->commit;
1864    return $note->id;
1865 }
1866
1867 __PACKAGE__->register_method(
1868     method    => 'create_hold_note',
1869     api_name  => 'open-ils.circ.hold_note.create',
1870     signature => q/
1871         Creates a new hold request note object
1872         @param authtoken The login session key
1873         @param note The hold note object to create
1874         @return ID of the new object on success, Event on error
1875         /
1876 );
1877
1878 sub create_hold_note {
1879    my( $self, $conn, $auth, $note ) = @_;
1880    my $e = new_editor(authtoken=>$auth, xact=>1);
1881    return $e->die_event unless $e->checkauth;
1882
1883    my $hold = $e->retrieve_action_hold_request($note->hold)
1884       or return $e->die_event;
1885    my $patron = $e->retrieve_actor_user($hold->usr)
1886       or return $e->die_event;
1887
1888    return $e->die_event unless
1889       $e->allowed('UPDATE_HOLD', $patron->home_ou); # FIXME: Using permcrud perm listed in fm_IDL.xml for ahrn.  Probably want something more specific
1890
1891    $e->create_action_hold_request_note($note) or return $e->die_event;
1892    $e->commit;
1893    return $note->id;
1894 }
1895
1896 __PACKAGE__->register_method(
1897     method    => 'reset_hold',
1898     api_name  => 'open-ils.circ.hold.reset',
1899     signature => q/
1900         Un-captures and un-targets a hold, essentially returning
1901         it to the state it was in directly after it was placed,
1902         then attempts to re-target the hold
1903         @param authtoken The login session key
1904         @param holdid The id of the hold
1905     /
1906 );
1907
1908
1909 sub reset_hold {
1910     my( $self, $conn, $auth, $holdid ) = @_;
1911     my $reqr;
1912     my ($hold, $evt) = $U->fetch_hold($holdid);
1913     return $evt if $evt;
1914     ($reqr, $evt) = $U->checksesperm($auth, 'UPDATE_HOLD');
1915     return $evt if $evt;
1916     $evt = _reset_hold($self, $reqr, $hold);
1917     return $evt if $evt;
1918     return 1;
1919 }
1920
1921
1922 __PACKAGE__->register_method(
1923     method   => 'reset_hold_batch',
1924     api_name => 'open-ils.circ.hold.reset.batch'
1925 );
1926
1927 sub reset_hold_batch {
1928     my($self, $conn, $auth, $hold_ids) = @_;
1929
1930     my $e = new_editor(authtoken => $auth);
1931     return $e->event unless $e->checkauth;
1932
1933     for my $hold_id ($hold_ids) {
1934
1935         my $hold = $e->retrieve_action_hold_request(
1936             [$hold_id, {flesh => 1, flesh_fields => {ahr => ['usr']}}])
1937             or return $e->event;
1938
1939         next unless $e->allowed('UPDATE_HOLD', $hold->usr->home_ou);
1940         _reset_hold($self, $e->requestor, $hold);
1941     }
1942
1943     return 1;
1944 }
1945
1946
1947 sub _reset_hold {
1948     my ($self, $reqr, $hold) = @_;
1949
1950     my $e = new_editor(xact =>1, requestor => $reqr);
1951
1952     $logger->info("reseting hold ".$hold->id);
1953
1954     my $hid = $hold->id;
1955
1956     if( $hold->capture_time and $hold->current_copy ) {
1957
1958         my $copy = $e->retrieve_asset_copy($hold->current_copy)
1959             or return $e->die_event;
1960
1961         if( $copy->status == OILS_COPY_STATUS_ON_HOLDS_SHELF ) {
1962             $logger->info("setting copy to status 'reshelving' on hold retarget");
1963             $copy->status(OILS_COPY_STATUS_RESHELVING);
1964             $copy->editor($e->requestor->id);
1965             $copy->edit_date('now');
1966             $e->update_asset_copy($copy) or return $e->die_event;
1967
1968         } elsif( $copy->status == OILS_COPY_STATUS_IN_TRANSIT ) {
1969
1970             # We don't want the copy to remain "in transit"
1971             $copy->status(OILS_COPY_STATUS_RESHELVING);
1972             $logger->warn("! reseting hold [$hid] that is in transit");
1973             my $transid = $e->search_action_hold_transit_copy({hold=>$hold->id},{idlist=>1})->[0];
1974
1975             if( $transid ) {
1976                 my $trans = $e->retrieve_action_transit_copy($transid);
1977                 if( $trans ) {
1978                     $logger->info("Aborting transit [$transid] on hold [$hid] reset...");
1979                     my $evt = OpenILS::Application::Circ::Transit::__abort_transit($e, $trans, $copy, 1, 1);
1980                     $logger->info("Transit abort completed with result $evt");
1981                     unless ("$evt" eq 1) {
1982                         $e->rollback;
1983                         return $evt;
1984                     }
1985                 }
1986             }
1987         }
1988     }
1989
1990     $hold->clear_capture_time;
1991     $hold->clear_current_copy;
1992     $hold->clear_shelf_time;
1993     $hold->clear_shelf_expire_time;
1994     $hold->clear_current_shelf_lib;
1995
1996     $e->update_action_hold_request($hold) or return $e->die_event;
1997     $e->commit;
1998
1999     $U->simplereq('open-ils.hold-targeter', 
2000         'open-ils.hold-targeter.target', {hold => $hold->id});
2001
2002     return undef;
2003 }
2004
2005
2006 __PACKAGE__->register_method(
2007     method    => 'fetch_open_title_holds',
2008     api_name  => 'open-ils.circ.open_holds.retrieve',
2009     signature => q/
2010         Returns a list ids of un-fulfilled holds for a given title id
2011         @param authtoken The login session key
2012         @param id the id of the item whose holds we want to retrieve
2013         @param type The hold type - M, T, I, V, C, F, R
2014     /
2015 );
2016
2017 sub fetch_open_title_holds {
2018     my( $self, $conn, $auth, $id, $type, $org ) = @_;
2019     my $e = new_editor( authtoken => $auth );
2020     return $e->event unless $e->checkauth;
2021
2022     $type ||= "T";
2023     $org  ||= $e->requestor->ws_ou;
2024
2025 #    return $e->search_action_hold_request(
2026 #        { target => $id, hold_type => $type, fulfillment_time => undef }, {idlist=>1});
2027
2028     # XXX make me return IDs in the future ^--
2029     my $holds = $e->search_action_hold_request(
2030         {
2031             target           => $id,
2032             cancel_time      => undef,
2033             hold_type        => $type,
2034             fulfillment_time => undef
2035         }
2036     );
2037
2038     flesh_hold_transits($holds);
2039     return $holds;
2040 }
2041
2042
2043 sub flesh_hold_transits {
2044     my $holds = shift;
2045     for my $hold ( @$holds ) {
2046         $hold->transit(
2047             $apputils->simplereq(
2048                 'open-ils.cstore',
2049                 "open-ils.cstore.direct.action.hold_transit_copy.search.atomic",
2050                 { hold => $hold->id },
2051                 { order_by => { ahtc => 'id desc' }, limit => 1 }
2052             )->[0]
2053         );
2054     }
2055 }
2056
2057 sub flesh_hold_notices {
2058     my( $holds, $e ) = @_;
2059     $e ||= new_editor();
2060
2061     for my $hold (@$holds) {
2062         my $notices = $e->search_action_hold_notification(
2063             [
2064                 { hold => $hold->id },
2065                 { order_by => { anh => 'notify_time desc' } },
2066             ],
2067             {idlist=>1}
2068         );
2069
2070         $hold->notify_count(scalar(@$notices));
2071         if( @$notices ) {
2072             my $n = $e->retrieve_action_hold_notification($$notices[0])
2073                 or return $e->event;
2074             $hold->notify_time($n->notify_time);
2075         }
2076     }
2077 }
2078
2079
2080 __PACKAGE__->register_method(
2081     method    => 'fetch_captured_holds',
2082     api_name  => 'open-ils.circ.captured_holds.on_shelf.retrieve',
2083     stream    => 1,
2084     authoritative => 1,
2085     signature => q/
2086         Returns a list of un-fulfilled holds (on the Holds Shelf) for a given title id
2087         @param authtoken The login session key
2088         @param org The org id of the location in question
2089         @param match_copy A specific copy to limit to
2090     /
2091 );
2092
2093 __PACKAGE__->register_method(
2094     method    => 'fetch_captured_holds',
2095     api_name  => 'open-ils.circ.captured_holds.id_list.on_shelf.retrieve',
2096     stream    => 1,
2097     authoritative => 1,
2098     signature => q/
2099         Returns list ids of un-fulfilled holds (on the Holds Shelf) for a given title id
2100         @param authtoken The login session key
2101         @param org The org id of the location in question
2102         @param match_copy A specific copy to limit to
2103     /
2104 );
2105
2106 __PACKAGE__->register_method(
2107     method    => 'fetch_captured_holds',
2108     api_name  => 'open-ils.circ.captured_holds.id_list.expired_on_shelf.retrieve',
2109     stream    => 1,
2110     authoritative => 1,
2111     signature => q/
2112         Returns list ids of shelf-expired un-fulfilled holds for a given title id
2113         @param authtoken The login session key
2114         @param org The org id of the location in question
2115         @param match_copy A specific copy to limit to
2116     /
2117 );
2118
2119 __PACKAGE__->register_method(
2120     method    => 'fetch_captured_holds',
2121     api_name  => 
2122       'open-ils.circ.captured_holds.expired_on_shelf_or_wrong_shelf.retrieve',
2123     stream    => 1,
2124     authoritative => 1,
2125     signature => q/
2126         Returns list of shelf-expired un-fulfilled holds OR wrong shelf holds
2127         for a given shelf lib
2128     /
2129 );
2130
2131 __PACKAGE__->register_method(
2132     method    => 'fetch_captured_holds',
2133     api_name  => 
2134       'open-ils.circ.captured_holds.id_list.expired_on_shelf_or_wrong_shelf.retrieve',
2135     stream    => 1,
2136     authoritative => 1,
2137     signature => q/
2138         Returns list of shelf-expired un-fulfilled holds OR wrong shelf holds
2139         for a given shelf lib
2140     /
2141 );
2142
2143
2144 sub fetch_captured_holds {
2145     my( $self, $conn, $auth, $org, $match_copy ) = @_;
2146
2147     my $e = new_editor(authtoken => $auth);
2148     return $e->die_event unless $e->checkauth;
2149     return $e->die_event unless $e->allowed('VIEW_HOLD'); # XXX rely on editor perm
2150
2151     $org ||= $e->requestor->ws_ou;
2152
2153     my $current_copy = { '!=' => undef };
2154     $current_copy = { '=' => $match_copy } if $match_copy;
2155
2156     my $query = {
2157         select => { alhr => ['id'] },
2158         from   => {
2159             alhr => {
2160                 acp => {
2161                     field => 'id',
2162                     fkey  => 'current_copy'
2163                 },
2164             }
2165         },
2166         where => {
2167             '+acp' => { status => OILS_COPY_STATUS_ON_HOLDS_SHELF, deleted => 'f' },
2168             '+alhr' => {
2169                 capture_time      => { "!=" => undef },
2170                 current_copy      => $current_copy,
2171                 fulfillment_time  => undef,
2172                 current_shelf_lib => $org
2173             }
2174         }
2175     };
2176     if($self->api_name =~ /expired/) {
2177         $query->{'where'}->{'+alhr'}->{'-or'} = {
2178                 shelf_expire_time => { '<' => 'today'},
2179                 cancel_time => { '!=' => undef },
2180         };
2181     }
2182     my $hold_ids = $e->json_query( $query );
2183
2184     if ($self->api_name =~ /wrong_shelf/) {
2185         # fetch holds whose current_shelf_lib is $org, but whose pickup 
2186         # lib is some other org unit.  Ignore already-retrieved holds.
2187         my $wrong_shelf =
2188             pickup_lib_changed_on_shelf_holds(
2189                 $e, $org, [map {$_->{id}} @$hold_ids]);
2190         # match the layout of other items in $hold_ids
2191         push (@$hold_ids, {id => $_}) for @$wrong_shelf;
2192     }
2193
2194
2195     for my $hold_id (@$hold_ids) {
2196         if($self->api_name =~ /id_list/) {
2197             $conn->respond($hold_id->{id});
2198             next;
2199         } else {
2200             $conn->respond(
2201                 $e->retrieve_action_hold_request([
2202                     $hold_id->{id},
2203                     {
2204                         flesh => 1,
2205                         flesh_fields => {ahr => ['notifications', 'transit', 'notes']},
2206                         order_by => {anh => 'notify_time desc'}
2207                     }
2208                 ])
2209             );
2210         }
2211     }
2212
2213     return undef;
2214 }
2215
2216 __PACKAGE__->register_method(
2217     method    => "print_expired_holds_stream",
2218     api_name  => "open-ils.circ.captured_holds.expired.print.stream",
2219     stream    => 1
2220 );
2221
2222 sub print_expired_holds_stream {
2223     my ($self, $client, $auth, $params) = @_;
2224
2225     # No need to check specific permissions: we're going to call another method
2226     # that will do that.
2227     my $e = new_editor("authtoken" => $auth);
2228     return $e->die_event unless $e->checkauth;
2229
2230     delete($$params{org_id}) unless (int($$params{org_id}));
2231     delete($$params{limit}) unless (int($$params{limit}));
2232     delete($$params{offset}) unless (int($$params{offset}));
2233     delete($$params{chunk_size}) unless (int($$params{chunk_size}));
2234     delete($$params{chunk_size}) if  ($$params{chunk_size} && $$params{chunk_size} > 50); # keep the size reasonable
2235     $$params{chunk_size} ||= 10;
2236     $client->max_chunk_size($$params{chunk_size});
2237
2238     $$params{org_id} = (defined $$params{org_id}) ? $$params{org_id}: $e->requestor->ws_ou;
2239
2240     my @hold_ids = $self->method_lookup(
2241         "open-ils.circ.captured_holds.id_list.expired_on_shelf.retrieve"
2242     )->run($auth, $params->{"org_id"});
2243
2244     if (!@hold_ids) {
2245         $e->disconnect;
2246         return;
2247     } elsif (defined $U->event_code($hold_ids[0])) {
2248         $e->disconnect;
2249         return $hold_ids[0];
2250     }
2251
2252     $logger->info("about to stream back up to " . scalar(@hold_ids) . " expired holds");
2253
2254     while (@hold_ids) {
2255         my @hid_chunk = splice @hold_ids, 0, $params->{"chunk_size"};
2256
2257         my $result_chunk = $e->json_query({
2258             "select" => {
2259                 "acp" => ["barcode"],
2260                 "au" => [qw/
2261                     first_given_name second_given_name family_name alias
2262                 /],
2263                 "acn" => ["label"],
2264                 "bre" => ["marc"],
2265                 "acpl" => ["name"]
2266             },
2267             "from" => {
2268                 "ahr" => {
2269                     "acp" => {
2270                         "field" => "id", "fkey" => "current_copy",
2271                         "join" => {
2272                             "acn" => {
2273                                 "field" => "id", "fkey" => "call_number",
2274                                 "join" => {
2275                                     "bre" => {
2276                                         "field" => "id", "fkey" => "record"
2277                                     }
2278                                 }
2279                             },
2280                             "acpl" => {"field" => "id", "fkey" => "location"}
2281                         }
2282                     },
2283                     "au" => {"field" => "id", "fkey" => "usr"}
2284                 }
2285             },
2286             "where" => {"+ahr" => {"id" => \@hid_chunk}}
2287         }) or return $e->die_event;
2288         $client->respond($result_chunk);
2289     }
2290
2291     $e->disconnect;
2292     undef;
2293 }
2294
2295 __PACKAGE__->register_method(
2296     method    => "check_title_hold_batch",
2297     api_name  => "open-ils.circ.title_hold.is_possible.batch",
2298     stream    => 1,
2299     signature => {
2300         desc  => '@see open-ils.circ.title_hold.is_possible.batch',
2301         params => [
2302             { desc => 'Authentication token',     type => 'string'},
2303             { desc => 'Array of Hash of named parameters', type => 'array'},
2304         ],
2305         return => {
2306             desc => 'Array of response objects',
2307             type => 'array'
2308         }
2309     }
2310 );
2311
2312 sub check_title_hold_batch {
2313     my($self, $client, $authtoken, $param_list, $oargs) = @_;
2314     foreach (@$param_list) {
2315         my ($res) = $self->method_lookup('open-ils.circ.title_hold.is_possible')->run($authtoken, $_, $oargs);
2316         $client->respond($res);
2317     }
2318     return undef;
2319 }
2320
2321
2322 __PACKAGE__->register_method(
2323     method    => "check_title_hold",
2324     api_name  => "open-ils.circ.title_hold.is_possible",
2325     signature => {
2326         desc  => 'Determines if a hold were to be placed by a given user, ' .
2327              'whether or not said hold would have any potential copies to fulfill it.' .
2328              'The named paramaters of the second argument include: ' .
2329              'patronid, titleid, volume_id, copy_id, mrid, depth, pickup_lib, hold_type, selection_ou. ' .
2330              'See perldoc ' . __PACKAGE__ . ' for more info on these fields.' ,
2331         params => [
2332             { desc => 'Authentication token',     type => 'string'},
2333             { desc => 'Hash of named parameters', type => 'object'},
2334         ],
2335         return => {
2336             desc => 'List of new message IDs (empty if none)',
2337             type => 'array'
2338         }
2339     }
2340 );
2341
2342 =head3 check_title_hold (token, hash)
2343
2344 The named fields in the hash are:
2345
2346  patronid     - ID of the hold recipient  (required)
2347  depth        - hold range depth          (default 0)
2348  pickup_lib   - destination for hold, fallback value for selection_ou
2349  selection_ou - ID of org_unit establishing hard and soft hold boundary settings
2350  issuanceid   - ID of the issuance to be held, required for Issuance level hold
2351  partid       - ID of the monograph part to be held, required for monograph part level hold
2352  titleid      - ID (BRN) of the title to be held, required for Title level hold
2353  volume_id    - required for Volume level hold
2354  copy_id      - required for Copy level hold
2355  mrid         - required for Meta-record level hold
2356  hold_type    - T, C (or R or F), I, V or M for Title, Copy, Issuance, Volume or Meta-record  (default "T")
2357
2358 All key/value pairs are passed on to do_possibility_checks.
2359
2360 =cut
2361
2362 # FIXME: better params checking.  what other params are required, if any?
2363 # FIXME: 3 copies of values confusing: $x, $params->{x} and $params{x}
2364 # FIXME: for example, $depth gets a default value, but then $$params{depth} is still
2365 # used in conditionals, where it may be undefined, causing a warning.
2366 # FIXME: specify proper usage/interaction of selection_ou and pickup_lib
2367
2368 sub check_title_hold {
2369     my( $self, $client, $authtoken, $params ) = @_;
2370     my $e = new_editor(authtoken=>$authtoken);
2371     return $e->event unless $e->checkauth;
2372
2373     my %params       = %$params;
2374     my $depth        = $params{depth}        || 0;
2375     my $selection_ou = $params{selection_ou} || $params{pickup_lib};
2376     my $oargs        = $params{oargs}        || {};
2377
2378     if($oargs->{events}) {
2379         @{$oargs->{events}} = grep { $e->allowed($_ . '.override', $e->requestor->ws_ou); } @{$oargs->{events}};
2380     }
2381
2382
2383     my $patron = $e->retrieve_actor_user($params{patronid})
2384         or return $e->event;
2385
2386     if( $e->requestor->id ne $patron->id ) {
2387         return $e->event unless
2388             $e->allowed('VIEW_HOLD_PERMIT', $patron->home_ou);
2389     }
2390
2391     return OpenILS::Event->new('PATRON_BARRED') if $U->is_true($patron->barred);
2392
2393     my $request_lib = $e->retrieve_actor_org_unit($e->requestor->ws_ou)
2394         or return $e->event;
2395
2396     my $soft_boundary = $U->ou_ancestor_setting_value($selection_ou, OILS_SETTING_HOLD_SOFT_BOUNDARY);
2397     my $hard_boundary = $U->ou_ancestor_setting_value($selection_ou, OILS_SETTING_HOLD_HARD_BOUNDARY);
2398
2399     my @status = ();
2400     my $return_depth = $hard_boundary; # default depth to return on success
2401     if(defined $soft_boundary and $depth < $soft_boundary) {
2402         # work up the tree and as soon as we find a potential copy, use that depth
2403         # also, make sure we don't go past the hard boundary if it exists
2404
2405         # our min boundary is the greater of user-specified boundary or hard boundary
2406         my $min_depth = (defined $hard_boundary and $hard_boundary > $depth) ?
2407             $hard_boundary : $depth;
2408
2409         my $depth = $soft_boundary;
2410         while($depth >= $min_depth) {
2411             $logger->info("performing hold possibility check with soft boundary $depth");
2412             @status = do_possibility_checks($e, $patron, $request_lib, $depth, %params);
2413             if ($status[0]) {
2414                 $return_depth = $depth;
2415                 last;
2416             }
2417             $depth--;
2418         }
2419     } elsif(defined $hard_boundary and $depth < $hard_boundary) {
2420         # there is no soft boundary, enforce the hard boundary if it exists
2421         $logger->info("performing hold possibility check with hard boundary $hard_boundary");
2422         @status = do_possibility_checks($e, $patron, $request_lib, $hard_boundary, %params);
2423     } else {
2424         # no boundaries defined, fall back to user specifed boundary or no boundary
2425         $logger->info("performing hold possibility check with no boundary");
2426         @status = do_possibility_checks($e, $patron, $request_lib, $params{depth}, %params);
2427     }
2428
2429     my $place_unfillable = 0;
2430     $place_unfillable = 1 if $e->allowed('PLACE_UNFILLABLE_HOLD', $e->requestor->ws_ou);
2431
2432     if ($status[0]) {
2433         return {
2434             "success" => 1,
2435             "depth" => $return_depth,
2436             "local_avail" => $status[1]
2437         };
2438     } elsif ($status[2]) {
2439         my $n = scalar @{$status[2]};
2440         return {"success" => 0, "last_event" => $status[2]->[$n - 1], "age_protected_copy" => $status[3], "place_unfillable" => $place_unfillable};
2441     } else {
2442         return {"success" => 0, "age_protected_copy" => $status[3], "place_unfillable" => $place_unfillable};
2443     }
2444 }
2445
2446
2447
2448 sub do_possibility_checks {
2449     my($e, $patron, $request_lib, $depth, %params) = @_;
2450
2451     my $issuanceid   = $params{issuanceid}      || "";
2452     my $partid       = $params{partid}      || "";
2453     my $titleid      = $params{titleid}      || "";
2454     my $volid        = $params{volume_id};
2455     my $copyid       = $params{copy_id};
2456     my $mrid         = $params{mrid}         || "";
2457     my $pickup_lib   = $params{pickup_lib};
2458     my $hold_type    = $params{hold_type}    || 'T';
2459     my $selection_ou = $params{selection_ou} || $pickup_lib;
2460     my $holdable_formats = $params{holdable_formats};
2461     my $oargs        = $params{oargs}        || {};
2462
2463
2464     my $copy;
2465     my $volume;
2466     my $title;
2467
2468     if( $hold_type eq OILS_HOLD_TYPE_FORCE || $hold_type eq OILS_HOLD_TYPE_RECALL || $hold_type eq OILS_HOLD_TYPE_COPY ) {
2469
2470         return $e->event unless $copy   = $e->retrieve_asset_copy($copyid);
2471         return $e->event unless $volume = $e->retrieve_asset_call_number($copy->call_number);
2472         return $e->event unless $title  = $e->retrieve_biblio_record_entry($volume->record);
2473
2474         return (1, 1, []) if( $hold_type eq OILS_HOLD_TYPE_RECALL || $hold_type eq OILS_HOLD_TYPE_FORCE);
2475         return verify_copy_for_hold(
2476             $patron, $e->requestor, $title, $copy, $pickup_lib, $request_lib, $oargs
2477         );
2478
2479     } elsif( $hold_type eq OILS_HOLD_TYPE_VOLUME ) {
2480
2481         return $e->event unless $volume = $e->retrieve_asset_call_number($volid);
2482         return $e->event unless $title  = $e->retrieve_biblio_record_entry($volume->record);
2483
2484         return _check_volume_hold_is_possible(
2485             $volume, $title, $depth, $request_lib, $patron, $e->requestor, $pickup_lib, $selection_ou, $oargs
2486         );
2487
2488     } elsif( $hold_type eq OILS_HOLD_TYPE_TITLE ) {
2489
2490         return _check_title_hold_is_possible(
2491             $titleid, $depth, $request_lib, $patron, $e->requestor, $pickup_lib, $selection_ou, undef, $oargs
2492         );
2493
2494     } elsif( $hold_type eq OILS_HOLD_TYPE_ISSUANCE ) {
2495
2496         return _check_issuance_hold_is_possible(
2497             $issuanceid, $depth, $request_lib, $patron, $e->requestor, $pickup_lib, $selection_ou, $oargs
2498         );
2499
2500     } elsif( $hold_type eq OILS_HOLD_TYPE_MONOPART ) {
2501
2502         return _check_monopart_hold_is_possible(
2503             $partid, $depth, $request_lib, $patron, $e->requestor, $pickup_lib, $selection_ou, $oargs
2504         );
2505
2506     } elsif( $hold_type eq OILS_HOLD_TYPE_METARECORD ) {
2507
2508         # pasing undef as the depth to filtered_records causes the depth
2509         # of the selection_ou to be used, which is not what we want here.
2510         $depth ||= 0;
2511
2512         my ($recs) = __PACKAGE__->method_lookup('open-ils.circ.holds.metarecord.filtered_records')->run($mrid, $holdable_formats, $selection_ou, $depth);
2513         my @status = ();
2514         for my $rec (@$recs) {
2515             @status = _check_title_hold_is_possible(
2516                 $rec, $depth, $request_lib, $patron, $e->requestor, $pickup_lib, $selection_ou, $holdable_formats, $oargs
2517             );
2518             last if $status[0];
2519         }
2520         return @status;
2521     }
2522 #   else { Unrecognized hold_type ! }   # FIXME: return error? or 0?
2523 }
2524
2525 sub MR_filter_records {
2526     my $self = shift;
2527     my $client = shift;
2528     my $m = shift;
2529     my $f = shift;
2530     my $o = shift;
2531     my $d = shift;
2532     my $opac_visible = shift;
2533     
2534     my $org_at_depth = defined($d) ? $U->org_unit_ancestor_at_depth($o, $d) : $o;
2535     return $U->storagereq(
2536         'open-ils.storage.metarecord.filtered_records.atomic', 
2537         $m, $f, $org_at_depth, $opac_visible
2538     );
2539 }
2540 __PACKAGE__->register_method(
2541     method   => 'MR_filter_records',
2542     api_name => 'open-ils.circ.holds.metarecord.filtered_records',
2543 );
2544
2545
2546 my %prox_cache;
2547 sub create_ranged_org_filter {
2548     my($e, $selection_ou, $depth) = @_;
2549
2550     # find the orgs from which this hold may be fulfilled,
2551     # based on the selection_ou and depth
2552
2553     my $top_org = $e->search_actor_org_unit([
2554         {parent_ou => undef},
2555         {flesh=>1, flesh_fields=>{aou=>['ou_type']}}])->[0];
2556     my %org_filter;
2557
2558     return () if $depth == $top_org->ou_type->depth;
2559
2560     my $org_list = $U->storagereq('open-ils.storage.actor.org_unit.descendants.atomic', $selection_ou, $depth);
2561     %org_filter = (circ_lib => []);
2562     push(@{$org_filter{circ_lib}}, $_->id) for @$org_list;
2563
2564     $logger->info("hold org filter at depth $depth and selection_ou ".
2565         "$selection_ou created list of @{$org_filter{circ_lib}}");
2566
2567     return %org_filter;
2568 }
2569
2570
2571 sub _check_title_hold_is_possible {
2572     my( $titleid, $depth, $request_lib, $patron, $requestor, $pickup_lib, $selection_ou, $holdable_formats, $oargs ) = @_;
2573     # $holdable_formats is now unused. We pre-filter the MR's records.
2574
2575     my $e = new_editor();
2576     my %org_filter = create_ranged_org_filter($e, $selection_ou, $depth);
2577
2578     # this monster will grab the id and circ_lib of all of the "holdable" copies for the given record
2579     my $copies = $e->json_query(
2580         {
2581             select => { acp => ['id', 'circ_lib'] },
2582               from => {
2583                 acp => {
2584                     acn => {
2585                         field  => 'id',
2586                         fkey   => 'call_number',
2587                         filter => { record => $titleid }
2588                     },
2589                     acpl => {
2590                                 field => 'id',
2591                                 filter => { holdable => 't', deleted => 'f' },
2592                                 fkey => 'location'
2593                             },
2594                     ccs  => { field => 'id', filter => { holdable => 't'}, fkey => 'status'   },
2595                     acpm => { field => 'target_copy', type => 'left' } # ignore part-linked copies
2596                 }
2597             },
2598             where => {
2599                 '+acp' => { circulate => 't', deleted => 'f', holdable => 't', %org_filter },
2600                 '+acpm' => { target_copy => undef } # ignore part-linked copies
2601             }
2602         }
2603     );
2604
2605     $logger->info("title possible found ".scalar(@$copies)." potential copies");
2606     return (
2607         0, 0, [
2608             new OpenILS::Event(
2609                 "HIGH_LEVEL_HOLD_HAS_NO_COPIES",
2610                 "payload" => {"fail_part" => "no_ultimate_items"}
2611             )
2612         ]
2613     ) unless @$copies;
2614
2615     # -----------------------------------------------------------------------
2616     # sort the copies into buckets based on their circ_lib proximity to
2617     # the patron's home_ou.
2618     # -----------------------------------------------------------------------
2619
2620     my $home_org = $patron->home_ou;
2621     my $req_org = $request_lib->id;
2622
2623     $logger->info("prox cache $home_org " . $prox_cache{$home_org});
2624
2625     $prox_cache{$home_org} =
2626         $e->search_actor_org_unit_proximity({from_org => $home_org})
2627         unless $prox_cache{$home_org};
2628     my $home_prox = $prox_cache{$home_org};
2629
2630     my %buckets;
2631     my %hash = map { ($_->to_org => $_->prox) } @$home_prox;
2632     push( @{$buckets{ $hash{$_->{circ_lib}} } }, $_->{id} ) for @$copies;
2633
2634     my @keys = sort { $a <=> $b } keys %buckets;
2635
2636
2637     if( $home_org ne $req_org ) {
2638       # -----------------------------------------------------------------------
2639       # shove the copies close to the request_lib into the primary buckets
2640       # directly before the farthest away copies.  That way, they are not
2641       # given priority, but they are checked before the farthest copies.
2642       # -----------------------------------------------------------------------
2643         $prox_cache{$req_org} =
2644             $e->search_actor_org_unit_proximity({from_org => $req_org})
2645             unless $prox_cache{$req_org};
2646         my $req_prox = $prox_cache{$req_org};
2647
2648         my %buckets2;
2649         my %hash2 = map { ($_->to_org => $_->prox) } @$req_prox;
2650         push( @{$buckets2{ $hash2{$_->{circ_lib}} } }, $_->{id} ) for @$copies;
2651
2652         my $highest_key = $keys[@keys - 1];  # the farthest prox in the exising buckets
2653         my $new_key = $highest_key - 0.5; # right before the farthest prox
2654         my @keys2   = sort { $a <=> $b } keys %buckets2;
2655         for my $key (@keys2) {
2656             last if $key >= $highest_key;
2657             push( @{$buckets{$new_key}}, $_ ) for @{$buckets2{$key}};
2658         }
2659     }
2660
2661     @keys = sort { $a <=> $b } keys %buckets;
2662
2663     my $title;
2664     my %seen;
2665     my @status;
2666     my $age_protect_only = 0;
2667     OUTER: for my $key (@keys) {
2668       my @cps = @{$buckets{$key}};
2669
2670       $logger->info("looking at " . scalar(@{$buckets{$key}}). " copies in proximity bucket $key");
2671
2672       for my $copyid (@cps) {
2673
2674          next if $seen{$copyid};
2675          $seen{$copyid} = 1; # there could be dupes given the merged buckets
2676          my $copy = $e->retrieve_asset_copy($copyid);
2677          $logger->debug("looking at bucket_key=$key, copy $copyid : circ_lib = " . $copy->circ_lib);
2678
2679          unless($title) { # grab the title if we don't already have it
2680             my $vol = $e->retrieve_asset_call_number(
2681                [ $copy->call_number, { flesh => 1, flesh_fields => { bre => ['fixed_fields'], acn => ['record'] } } ] );
2682             $title = $vol->record;
2683          }
2684
2685          @status = verify_copy_for_hold(
2686             $patron, $requestor, $title, $copy, $pickup_lib, $request_lib, $oargs);
2687
2688          $age_protect_only ||= $status[3];
2689          last OUTER if $status[0];
2690       }
2691     }
2692
2693     $status[3] = $age_protect_only;
2694     return @status;
2695 }
2696
2697 sub _check_issuance_hold_is_possible {
2698     my( $issuanceid, $depth, $request_lib, $patron, $requestor, $pickup_lib, $selection_ou, $oargs ) = @_;
2699
2700     my $e = new_editor();
2701     my %org_filter = create_ranged_org_filter($e, $selection_ou, $depth);
2702
2703     # this monster will grab the id and circ_lib of all of the "holdable" copies for the given record
2704     my $copies = $e->json_query(
2705         {
2706             select => { acp => ['id', 'circ_lib'] },
2707               from => {
2708                 acp => {
2709                     sitem => {
2710                         field  => 'unit',
2711                         fkey   => 'id',
2712                         filter => { issuance => $issuanceid }
2713                     },
2714                     acpl => {
2715                         field => 'id',
2716                         filter => { holdable => 't', deleted => 'f' },
2717                         fkey => 'location'
2718                     },
2719                     ccs  => { field => 'id', filter => { holdable => 't'}, fkey => 'status'   }
2720                 }
2721             },
2722             where => {
2723                 '+acp' => { circulate => 't', deleted => 'f', holdable => 't', %org_filter }
2724             },
2725             distinct => 1
2726         }
2727     );
2728
2729     $logger->info("issuance possible found ".scalar(@$copies)." potential copies");
2730
2731     my $empty_ok;
2732     if (!@$copies) {
2733         $empty_ok = $e->retrieve_config_global_flag('circ.holds.empty_issuance_ok');
2734         $empty_ok = ($empty_ok and $U->is_true($empty_ok->enabled));
2735
2736         return (
2737             0, 0, [
2738                 new OpenILS::Event(
2739                     "HIGH_LEVEL_HOLD_HAS_NO_COPIES",
2740                     "payload" => {"fail_part" => "no_ultimate_items"}
2741                 )
2742             ]
2743         ) unless $empty_ok;
2744
2745         return (1, 0);
2746     }
2747
2748     # -----------------------------------------------------------------------
2749     # sort the copies into buckets based on their circ_lib proximity to
2750     # the patron's home_ou.
2751     # -----------------------------------------------------------------------
2752
2753     my $home_org = $patron->home_ou;
2754     my $req_org = $request_lib->id;
2755
2756     $logger->info("prox cache $home_org " . $prox_cache{$home_org});
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
2763     my %buckets;
2764     my %hash = map { ($_->to_org => $_->prox) } @$home_prox;
2765     push( @{$buckets{ $hash{$_->{circ_lib}} } }, $_->{id} ) for @$copies;
2766
2767     my @keys = sort { $a <=> $b } keys %buckets;
2768
2769
2770     if( $home_org ne $req_org ) {
2771       # -----------------------------------------------------------------------
2772       # shove the copies close to the request_lib into the primary buckets
2773       # directly before the farthest away copies.  That way, they are not
2774       # given priority, but they are checked before the farthest copies.
2775       # -----------------------------------------------------------------------
2776         $prox_cache{$req_org} =
2777             $e->search_actor_org_unit_proximity({from_org => $req_org})
2778             unless $prox_cache{$req_org};
2779         my $req_prox = $prox_cache{$req_org};
2780
2781         my %buckets2;
2782         my %hash2 = map { ($_->to_org => $_->prox) } @$req_prox;
2783         push( @{$buckets2{ $hash2{$_->{circ_lib}} } }, $_->{id} ) for @$copies;
2784
2785         my $highest_key = $keys[@keys - 1];  # the farthest prox in the exising buckets
2786         my $new_key = $highest_key - 0.5; # right before the farthest prox
2787         my @keys2   = sort { $a <=> $b } keys %buckets2;
2788         for my $key (@keys2) {
2789             last if $key >= $highest_key;
2790             push( @{$buckets{$new_key}}, $_ ) for @{$buckets2{$key}};
2791         }
2792     }
2793
2794     @keys = sort { $a <=> $b } keys %buckets;
2795
2796     my $title;
2797     my %seen;
2798     my @status;
2799     my $age_protect_only = 0;
2800     OUTER: for my $key (@keys) {
2801       my @cps = @{$buckets{$key}};
2802
2803       $logger->info("looking at " . scalar(@{$buckets{$key}}). " copies in proximity bucket $key");
2804
2805       for my $copyid (@cps) {
2806
2807          next if $seen{$copyid};
2808          $seen{$copyid} = 1; # there could be dupes given the merged buckets
2809          my $copy = $e->retrieve_asset_copy($copyid);
2810          $logger->debug("looking at bucket_key=$key, copy $copyid : circ_lib = " . $copy->circ_lib);
2811
2812          unless($title) { # grab the title if we don't already have it
2813             my $vol = $e->retrieve_asset_call_number(
2814                [ $copy->call_number, { flesh => 1, flesh_fields => { bre => ['fixed_fields'], acn => ['record'] } } ] );
2815             $title = $vol->record;
2816          }
2817
2818          @status = verify_copy_for_hold(
2819             $patron, $requestor, $title, $copy, $pickup_lib, $request_lib, $oargs);
2820
2821          $age_protect_only ||= $status[3];
2822          last OUTER if $status[0];
2823       }
2824     }
2825
2826     if (!$status[0]) {
2827         if (!defined($empty_ok)) {
2828             $empty_ok = $e->retrieve_config_global_flag('circ.holds.empty_issuance_ok');
2829             $empty_ok = ($empty_ok and $U->is_true($empty_ok->enabled));
2830         }
2831
2832         return (1,0) if ($empty_ok);
2833     }
2834     $status[3] = $age_protect_only;
2835     return @status;
2836 }
2837
2838 sub _check_monopart_hold_is_possible {
2839     my( $partid, $depth, $request_lib, $patron, $requestor, $pickup_lib, $selection_ou, $oargs ) = @_;
2840
2841     my $e = new_editor();
2842     my %org_filter = create_ranged_org_filter($e, $selection_ou, $depth);
2843
2844     # this monster will grab the id and circ_lib of all of the "holdable" copies for the given record
2845     my $copies = $e->json_query(
2846         {
2847             select => { acp => ['id', 'circ_lib'] },
2848               from => {
2849                 acp => {
2850                     acpm => {
2851                         field  => 'target_copy',
2852                         fkey   => 'id',
2853                         filter => { part => $partid }
2854                     },
2855                     acpl => {
2856                         field => 'id',
2857                         filter => { holdable => 't', deleted => 'f' },
2858                         fkey => 'location'
2859                     },
2860                     ccs  => { field => 'id', filter => { holdable => 't'}, fkey => 'status'   }
2861                 }
2862             },
2863             where => {
2864                 '+acp' => { circulate => 't', deleted => 'f', holdable => 't', %org_filter }
2865             },
2866             distinct => 1
2867         }
2868     );
2869
2870     $logger->info("monopart possible found ".scalar(@$copies)." potential copies");
2871
2872     my $empty_ok;
2873     if (!@$copies) {
2874         $empty_ok = $e->retrieve_config_global_flag('circ.holds.empty_part_ok');
2875         $empty_ok = ($empty_ok and $U->is_true($empty_ok->enabled));
2876
2877         return (
2878             0, 0, [
2879                 new OpenILS::Event(
2880                     "HIGH_LEVEL_HOLD_HAS_NO_COPIES",
2881                     "payload" => {"fail_part" => "no_ultimate_items"}
2882                 )
2883             ]
2884         ) unless $empty_ok;
2885
2886         return (1, 0);
2887     }
2888
2889     # -----------------------------------------------------------------------
2890     # sort the copies into buckets based on their circ_lib proximity to
2891     # the patron's home_ou.
2892     # -----------------------------------------------------------------------
2893
2894     my $home_org = $patron->home_ou;
2895     my $req_org = $request_lib->id;
2896
2897     $logger->info("prox cache $home_org " . $prox_cache{$home_org});
2898
2899     $prox_cache{$home_org} =
2900         $e->search_actor_org_unit_proximity({from_org => $home_org})
2901         unless $prox_cache{$home_org};
2902     my $home_prox = $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 $hold = $e->retrieve_action_hold_request(
3381         [
3382             $hold_id,
3383             {
3384                 flesh => 1,
3385                 flesh_fields => { ahr => [ 'current_copy', 'usr', 'notes' ] }
3386             }
3387         ]
3388     ) or return $e->event;
3389
3390     if($hold->usr->id ne $e->requestor->id) {
3391         # caller is asking for someone else's hold
3392         $e->allowed('VIEW_HOLD') or return $e->event;
3393         $hold->notes( # filter out any non-staff ("private") notes (unless marked as public)
3394             [ grep { $U->is_true($_->staff) or $U->is_true($_->pub) } @{$hold->notes} ] );
3395
3396     } else {
3397         # caller is asking for own hold, but may not have permission to view staff notes
3398         unless($e->allowed('VIEW_HOLD')) {
3399             $hold->notes( # filter out any staff notes (unless marked as public)
3400                 [ grep { !$U->is_true($_->staff) or $U->is_true($_->pub) } @{$hold->notes} ] );
3401         }
3402     }
3403
3404     my $user = $hold->usr;
3405     $hold->usr($user->id);
3406
3407
3408     my( $mvr, $volume, $copy, $issuance, $part, $bre ) = find_hold_mvr($e, $hold, $args->{suppress_mvr});
3409
3410     flesh_hold_notices([$hold], $e) unless $args->{suppress_notices};
3411     flesh_hold_transits([$hold]) unless $args->{suppress_transits};
3412
3413     my $details = retrieve_hold_queue_status_impl($e, $hold);
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, $no_mvr ) = @_;
3450
3451     my $tid;
3452     my $copy;
3453     my $volume;
3454     my $issuance;
3455     my $part;
3456
3457     if( $hold->hold_type eq OILS_HOLD_TYPE_METARECORD ) {
3458         my $mr = $e->retrieve_metabib_metarecord($hold->target)
3459             or return $e->event;
3460         $tid = $mr->master_record;
3461
3462     } elsif( $hold->hold_type eq OILS_HOLD_TYPE_TITLE ) {
3463         $tid = $hold->target;
3464
3465     } elsif( $hold->hold_type eq OILS_HOLD_TYPE_VOLUME ) {
3466         $volume = $e->retrieve_asset_call_number($hold->target)
3467             or return $e->event;
3468         $tid = $volume->record;
3469
3470     } elsif( $hold->hold_type eq OILS_HOLD_TYPE_ISSUANCE ) {
3471         $issuance = $e->retrieve_serial_issuance([
3472             $hold->target,
3473             {flesh => 1, flesh_fields => {siss => [ qw/subscription/ ]}}
3474         ]) or return $e->event;
3475
3476         $tid = $issuance->subscription->record_entry;
3477
3478     } elsif( $hold->hold_type eq OILS_HOLD_TYPE_MONOPART ) {
3479         $part = $e->retrieve_biblio_monograph_part([
3480             $hold->target
3481         ]) or return $e->event;
3482
3483         $tid = $part->record;
3484
3485     } 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 ) {
3486         $copy = $e->retrieve_asset_copy([
3487             $hold->target,
3488             {flesh => 1, flesh_fields => {acp => ['call_number']}}
3489         ]) or return $e->event;
3490
3491         $volume = $copy->call_number;
3492         $tid = $volume->record;
3493     }
3494
3495     if(!$copy and ref $hold->current_copy ) {
3496         $copy = $hold->current_copy;
3497         $hold->current_copy($copy->id);
3498     }
3499
3500     if(!$volume and $copy) {
3501         $volume = $e->retrieve_asset_call_number($copy->call_number);
3502     }
3503
3504     # TODO return metarcord mvr for M holds
3505     my $title = $e->retrieve_biblio_record_entry($tid);
3506     return ( ($no_mvr) ? undef : $U->record_to_mvr($title), $volume, $copy, $issuance, $part, $title );
3507 }
3508
3509 __PACKAGE__->register_method(
3510     method    => 'clear_shelf_cache',
3511     api_name  => 'open-ils.circ.hold.clear_shelf.get_cache',
3512     stream    => 1,
3513     signature => {
3514         desc => q/
3515             Returns the holds processed with the given cache key
3516         /
3517     }
3518 );
3519
3520 sub clear_shelf_cache {
3521     my($self, $client, $auth, $cache_key, $chunk_size) = @_;
3522     my $e = new_editor(authtoken => $auth, xact => 1);
3523     return $e->die_event unless $e->checkauth and $e->allowed('VIEW_HOLD');
3524
3525     $chunk_size ||= 25;
3526     $client->max_chunk_size($chunk_size) if ($client->can('max_chunk_size'));
3527
3528     my $hold_data = OpenSRF::Utils::Cache->new('global')->get_cache($cache_key);
3529
3530     if (!$hold_data) {
3531         $logger->info("no hold data found in cache"); # XXX TODO return event
3532         $e->rollback;
3533         return undef;
3534     }
3535
3536     my $maximum = 0;
3537     foreach (keys %$hold_data) {
3538         $maximum += scalar(@{ $hold_data->{$_} });
3539     }
3540     $client->respond({"maximum" => $maximum, "progress" => 0});
3541
3542     for my $action (sort keys %$hold_data) {
3543         while (@{$hold_data->{$action}}) {
3544             my @hid_chunk = splice @{$hold_data->{$action}}, 0, $chunk_size;
3545
3546             my $result_chunk = $e->json_query({
3547                 "select" => {
3548                     "acp" => ["barcode"],
3549                     "au" => [qw/
3550                         first_given_name second_given_name family_name alias
3551                     /],
3552                     "acn" => ["label"],
3553                     "acnp" => [{column => "label", alias => "prefix"}],
3554                     "acns" => [{column => "label", alias => "suffix"}],
3555                     "bre" => ["marc"],
3556                     "acpl" => ["name"],
3557                     "ahr" => ["id"]
3558                 },
3559                 "from" => {
3560                     "ahr" => {
3561                         "acp" => {
3562                             "field" => "id", "fkey" => "current_copy",
3563                             "join" => {
3564                                 "acn" => {
3565                                     "field" => "id", "fkey" => "call_number",
3566                                     "join" => {
3567                                         "bre" => {
3568                                             "field" => "id", "fkey" => "record"
3569                                         },
3570                                         "acnp" => {
3571                                             "field" => "id", "fkey" => "prefix"
3572                                         },
3573                                         "acns" => {
3574                                             "field" => "id", "fkey" => "suffix"
3575                                         }
3576                                     }
3577                                 },
3578                                 "acpl" => {"field" => "id", "fkey" => "location"}
3579                             }
3580                         },
3581                         "au" => {"field" => "id", "fkey" => "usr"}
3582                     }
3583                 },
3584                 "where" => {"+ahr" => {"id" => \@hid_chunk}}
3585             }, {"substream" => 1}) or return $e->die_event;
3586
3587             $client->respond([
3588                 map {
3589                     +{"action" => $action, "hold_details" => $_}
3590                 } @$result_chunk
3591             ]);
3592         }
3593     }
3594
3595     $e->rollback;
3596     return undef;
3597 }
3598
3599
3600 __PACKAGE__->register_method(
3601     method    => 'clear_shelf_process',
3602     stream    => 1,
3603     api_name  => 'open-ils.circ.hold.clear_shelf.process',
3604     signature => {
3605         desc => q/
3606             1. Find all holds that have expired on the holds shelf
3607             2. Cancel the holds
3608             3. If a clear-shelf status is configured, put targeted copies into this status
3609             4. Divide copies into 3 groups: items to transit, items to reshelve, and items
3610                 that are needed for holds.  No subsequent action is taken on the holds
3611                 or items after grouping.
3612         /
3613     }
3614 );
3615
3616 sub clear_shelf_process {
3617     my($self, $client, $auth, $org_id, $match_copy, $chunk_size) = @_;
3618
3619     my $e = new_editor(authtoken=>$auth);
3620     $e->checkauth or return $e->die_event;
3621     my $cache = OpenSRF::Utils::Cache->new('global');
3622
3623     $org_id ||= $e->requestor->ws_ou;
3624     $e->allowed('UPDATE_HOLD', $org_id) or return $e->die_event;
3625
3626     my $copy_status = $U->ou_ancestor_setting_value($org_id, 'circ.holds.clear_shelf.copy_status');
3627
3628     my @hold_ids = $self->method_lookup(
3629         "open-ils.circ.captured_holds.id_list.expired_on_shelf.retrieve"
3630     )->run($auth, $org_id, $match_copy);
3631
3632     $e->xact_begin;
3633
3634     my @holds;
3635     my @canceled_holds; # newly canceled holds
3636     $chunk_size ||= 25; # chunked status updates
3637     $client->max_chunk_size($chunk_size) if ($client->can('max_chunk_size'));
3638
3639     my $counter = 0;
3640     for my $hold_id (@hold_ids) {
3641
3642         $logger->info("Clear shelf processing hold $hold_id");
3643
3644         my $hold = $e->retrieve_action_hold_request([
3645             $hold_id, {
3646                 flesh => 1,
3647                 flesh_fields => {ahr => ['current_copy']}
3648             }
3649         ]);
3650
3651         if (!$hold->cancel_time) { # may be canceled but still on the holds shelf
3652             $hold->cancel_time('now');
3653             $hold->cancel_cause(2); # Hold Shelf expiration
3654             $e->update_action_hold_request($hold) or return $e->die_event;
3655             push(@canceled_holds, $hold_id);
3656         }
3657
3658         my $copy = $hold->current_copy;
3659
3660         if($copy_status or $copy_status == 0) {
3661             # if a clear-shelf copy status is defined, update the copy
3662             $copy->status($copy_status);
3663             $copy->edit_date('now');
3664             $copy->editor($e->requestor->id);
3665             $e->update_asset_copy($copy) or return $e->die_event;
3666         }
3667
3668         push(@holds, $hold);
3669         $client->respond({maximum => scalar(@holds), progress => $counter}) if ( (++$counter % $chunk_size) == 0);
3670     }
3671
3672     if ($e->commit) {
3673
3674         my %cache_data = (
3675             hold => [],
3676             transit => [],
3677             shelf => [],
3678             pl_changed => pickup_lib_changed_on_shelf_holds($e, $org_id, \@hold_ids)
3679         );
3680
3681         for my $hold (@holds) {
3682
3683             my $copy = $hold->current_copy;
3684             my ($alt_hold) = __PACKAGE__->find_nearest_permitted_hold($e, $copy, $e->requestor, 1);
3685
3686             if($alt_hold and !$match_copy) {
3687
3688                 push(@{$cache_data{hold}}, $hold->id); # copy is needed for a hold
3689
3690             } elsif($copy->circ_lib != $e->requestor->ws_ou) {
3691
3692                 push(@{$cache_data{transit}}, $hold->id); # copy needs to transit
3693
3694             } else {
3695
3696                 push(@{$cache_data{shelf}}, $hold->id); # copy needs to go back to the shelf
3697             }
3698         }
3699
3700         my $cache_key = md5_hex(time . $$ . rand());
3701         $logger->info("clear_shelf_cache: storing under $cache_key");
3702         $cache->put_cache($cache_key, \%cache_data, 7200); # TODO: 2 hours.  configurable?
3703
3704         # tell the client we're done
3705         $client->respond_complete({cache_key => $cache_key});
3706
3707         # ------------
3708         # fire off the hold cancelation trigger and wait for response so don't flood the service
3709
3710         # refetch the holds to pick up the caclulated cancel_time,
3711         # which may be needed by Action/Trigger
3712         $e->xact_begin;
3713         my $updated_holds = [];
3714         $updated_holds = $e->search_action_hold_request({id => \@canceled_holds}, {substream => 1}) if (@canceled_holds > 0);
3715         $e->rollback;
3716
3717         $U->create_events_for_hook(
3718             'hold_request.cancel.expire_holds_shelf',
3719             $_, $org_id, undef, undef, 1) for @$updated_holds;
3720
3721     } else {
3722         # tell the client we're done
3723         $client->respond_complete;
3724     }
3725 }
3726
3727 # returns IDs for holds that are on the holds shelf but 
3728 # have had their pickup_libs change while on the shelf.
3729 sub pickup_lib_changed_on_shelf_holds {
3730     my $e = shift;
3731     my $org_id = shift;
3732     my $ignore_holds = shift;
3733     $ignore_holds = [$ignore_holds] if !ref($ignore_holds);
3734
3735     my $query = {
3736         select => { alhr => ['id'] },
3737         from   => {
3738             alhr => {
3739                 acp => {
3740                     field => 'id',
3741                     fkey  => 'current_copy'
3742                 },
3743             }
3744         },
3745         where => {
3746             '+acp' => { status => OILS_COPY_STATUS_ON_HOLDS_SHELF },
3747             '+alhr' => {
3748                 capture_time     => { "!=" => undef },
3749                 fulfillment_time => undef,
3750                 current_shelf_lib => $org_id,
3751                 pickup_lib => {'!='  => {'+alhr' => 'current_shelf_lib'}}
3752             }
3753         }
3754     };
3755
3756     $query->{where}->{'+alhr'}->{id} =
3757         {'not in' => $ignore_holds} if @$ignore_holds;
3758
3759     my $hold_ids = $e->json_query($query);
3760     return [ map { $_->{id} } @$hold_ids ];
3761 }
3762
3763 __PACKAGE__->register_method(
3764     method    => 'usr_hold_summary',
3765     api_name  => 'open-ils.circ.holds.user_summary',
3766     signature => q/
3767         Returns a summary of holds statuses for a given user
3768     /
3769 );
3770
3771 sub usr_hold_summary {
3772     my($self, $conn, $auth, $user_id) = @_;
3773
3774     my $e = new_editor(authtoken=>$auth);
3775     $e->checkauth or return $e->event;
3776     $e->allowed('VIEW_HOLD') or return $e->event;
3777
3778     my $holds = $e->search_action_hold_request(
3779         {
3780             usr =>  $user_id ,
3781             fulfillment_time => undef,
3782             cancel_time      => undef,
3783         }
3784     );
3785
3786     my %summary = (1 => 0, 2 => 0, 3 => 0, 4 => 0);
3787     $summary{_hold_status($e, $_)} += 1 for @$holds;
3788     return \%summary;
3789 }
3790
3791
3792
3793 __PACKAGE__->register_method(
3794     method    => 'hold_has_copy_at',
3795     api_name  => 'open-ils.circ.hold.has_copy_at',
3796     signature => {
3797         desc   =>
3798                 'Returns the ID of the found copy and name of the shelving location if there is ' .
3799                 'an available copy at the specified org unit.  Returns empty hash otherwise.  '   .
3800                 'The anticipated use for this method is to determine whether an item is '         .
3801                 'available at the library where the user is placing the hold (or, alternatively, '.
3802                 'at the pickup library) to encourage bypassing the hold placement and just '      .
3803                 'checking out the item.' ,
3804         params => [
3805             { desc => 'Authentication Token', type => 'string' },
3806             { desc => 'Method Arguments.  Options include: hold_type, hold_target, org_unit.  '
3807                     . 'hold_type is the hold type code (T, V, C, M, ...).  '
3808                     . 'hold_target is the identifier of the hold target object.  '
3809                     . 'org_unit is org unit ID.',
3810               type => 'object'
3811             }
3812         ],
3813         return => {
3814             desc => q/Result hash like { "copy" : copy_id, "location" : location_name }, empty hash on misses, event on error./,
3815             type => 'object'
3816         }
3817     }
3818 );
3819
3820 sub hold_has_copy_at {
3821     my($self, $conn, $auth, $args) = @_;
3822
3823     my $e = new_editor(authtoken=>$auth);
3824     $e->checkauth or return $e->event;
3825
3826     my $hold_type   = $$args{hold_type};
3827     my $hold_target = $$args{hold_target};
3828     my $org_unit    = $$args{org_unit};
3829
3830     my $query = {
3831         select => {acp => ['id'], acpl => ['name']},
3832         from   => {
3833             acp => {
3834                 acpl => {
3835                     field => 'id',
3836                     filter => { holdable => 't', deleted => 'f' },
3837                     fkey => 'location'
3838                 },
3839                 ccs  => {field => 'id', filter => { holdable => 't'}, fkey => 'status'  }
3840             }
3841         },
3842         where => {'+acp' => { circulate => 't', deleted => 'f', holdable => 't', circ_lib => $org_unit, status => [0,7]}},
3843         limit => 1
3844     };
3845
3846     if($hold_type eq 'C' or $hold_type eq 'F' or $hold_type eq 'R') {
3847
3848         $query->{where}->{'+acp'}->{id} = $hold_target;
3849
3850     } elsif($hold_type eq 'V') {
3851
3852         $query->{where}->{'+acp'}->{call_number} = $hold_target;
3853
3854     } elsif($hold_type eq 'P') {
3855
3856         $query->{from}->{acp}->{acpm} = {
3857             field  => 'target_copy',
3858             fkey   => 'id',
3859             filter => {part => $hold_target},
3860         };
3861
3862     } elsif($hold_type eq 'I') {
3863
3864         $query->{from}->{acp}->{sitem} = {
3865             field  => 'unit',
3866             fkey   => 'id',
3867             filter => {issuance => $hold_target},
3868         };
3869
3870     } elsif($hold_type eq 'T') {
3871
3872         $query->{from}->{acp}->{acn} = {
3873             field  => 'id',
3874             fkey   => 'call_number',
3875             'join' => {
3876                 bre => {
3877                     field  => 'id',
3878                     filter => {id => $hold_target},
3879                     fkey   => 'record'
3880                 }
3881             }
3882         };
3883
3884     } else {
3885
3886         $query->{from}->{acp}->{acn} = {
3887             field => 'id',
3888             fkey  => 'call_number',
3889             join  => {
3890                 bre => {
3891                     field => 'id',
3892                     fkey  => 'record',
3893                     join  => {
3894                         mmrsm => {
3895                             field  => 'source',
3896                             fkey   => 'id',
3897                             filter => {metarecord => $hold_target},
3898                         }
3899                     }
3900                 }
3901             }
3902         };
3903     }
3904
3905     my $res = $e->json_query($query)->[0] or return {};
3906     return {copy => $res->{id}, location => $res->{name}} if $res;
3907 }
3908
3909
3910 # returns true if the user already has an item checked out
3911 # that could be used to fulfill the requested hold.
3912 sub hold_item_is_checked_out {
3913     my($e, $user_id, $hold_type, $hold_target) = @_;
3914
3915     my $query = {
3916         select => {acp => ['id']},
3917         from   => {acp => {}},
3918         where  => {
3919             '+acp' => {
3920                 id => {
3921                     in => { # copies for circs the user has checked out
3922                         select => {circ => ['target_copy']},
3923                         from   => 'circ',
3924                         where  => {
3925                             usr => $user_id,
3926                             checkin_time => undef,
3927                             '-or' => [
3928                                 {stop_fines => ["MAXFINES","LONGOVERDUE"]},
3929                                 {stop_fines => undef}
3930                             ],
3931                         }
3932                     }
3933                 }
3934             }
3935         },
3936         limit => 1
3937     };
3938
3939     if($hold_type eq 'C' || $hold_type eq 'R' || $hold_type eq 'F') {
3940
3941         $query->{where}->{'+acp'}->{id}->{in}->{where}->{'target_copy'} = $hold_target;
3942
3943     } elsif($hold_type eq 'V') {
3944
3945         $query->{where}->{'+acp'}->{call_number} = $hold_target;
3946
3947      } elsif($hold_type eq 'P') {
3948
3949         $query->{from}->{acp}->{acpm} = {
3950             field  => 'target_copy',
3951             fkey   => 'id',
3952             filter => {part => $hold_target},
3953         };
3954
3955      } elsif($hold_type eq 'I') {
3956
3957         $query->{from}->{acp}->{sitem} = {
3958             field  => 'unit',
3959             fkey   => 'id',
3960             filter => {issuance => $hold_target},
3961         };
3962
3963     } elsif($hold_type eq 'T') {
3964
3965         $query->{from}->{acp}->{acn} = {
3966             field  => 'id',
3967             fkey   => 'call_number',
3968             'join' => {
3969                 bre => {
3970                     field  => 'id',
3971                     filter => {id => $hold_target},
3972                     fkey   => 'record'
3973                 }
3974             }
3975         };
3976
3977     } else {
3978
3979         $query->{from}->{acp}->{acn} = {
3980             field => 'id',
3981             fkey => 'call_number',
3982             join => {
3983                 bre => {
3984                     field => 'id',
3985                     fkey => 'record',
3986                     join => {
3987                         mmrsm => {
3988                             field => 'source',
3989                             fkey => 'id',
3990                             filter => {metarecord => $hold_target},
3991                         }
3992                     }
3993                 }
3994             }
3995         };
3996     }
3997
3998     return $e->json_query($query)->[0];
3999 }
4000
4001 __PACKAGE__->register_method(
4002     method    => 'change_hold_title',
4003     api_name  => 'open-ils.circ.hold.change_title',
4004     signature => {
4005         desc => q/
4006             Updates all title level holds targeting the specified bibs to point a new bib./,
4007         params => [
4008             { desc => 'Authentication Token', type => 'string' },
4009             { desc => 'New Target Bib Id',    type => 'number' },
4010             { desc => 'Old Target Bib Ids',   type => 'array'  },
4011         ],
4012         return => { desc => '1 on success' }
4013     }
4014 );
4015
4016 __PACKAGE__->register_method(
4017     method    => 'change_hold_title_for_specific_holds',
4018     api_name  => 'open-ils.circ.hold.change_title.specific_holds',
4019     signature => {
4020         desc => q/
4021             Updates specified holds to target new bib./,
4022         params => [
4023             { desc => 'Authentication Token', type => 'string' },
4024             { desc => 'New Target Bib Id',    type => 'number' },
4025             { desc => 'Holds Ids for holds to update',   type => 'array'  },
4026         ],
4027         return => { desc => '1 on success' }
4028     }
4029 );
4030
4031
4032 sub change_hold_title {
4033     my( $self, $client, $auth, $new_bib_id, $bib_ids ) = @_;
4034
4035     my $e = new_editor(authtoken=>$auth, xact=>1);
4036     return $e->die_event unless $e->checkauth;
4037
4038     my $holds = $e->search_action_hold_request(
4039         [
4040             {
4041                 cancel_time      => undef,
4042                 fulfillment_time => undef,
4043                 hold_type        => 'T',
4044                 target           => $bib_ids
4045             },
4046             {
4047                 flesh        => 1,
4048                 flesh_fields => { ahr => ['usr'] }
4049             }
4050         ],
4051         { substream => 1 }
4052     );
4053
4054     for my $hold (@$holds) {
4055         $e->allowed('UPDATE_HOLD', $hold->usr->home_ou) or return $e->die_event;
4056         $logger->info("Changing hold " . $hold->id . " target from " . $hold->target . " to $new_bib_id in title hold target change");
4057         $hold->target( $new_bib_id );
4058         $e->update_action_hold_request($hold) or return $e->die_event;
4059     }
4060
4061     $e->commit;
4062
4063     _reset_hold($self, $e->requestor, $_) for @$holds;
4064
4065     return 1;
4066 }
4067
4068 sub change_hold_title_for_specific_holds {
4069     my( $self, $client, $auth, $new_bib_id, $hold_ids ) = @_;
4070
4071     my $e = new_editor(authtoken=>$auth, xact=>1);
4072     return $e->die_event unless $e->checkauth;
4073
4074     my $holds = $e->search_action_hold_request(
4075         [
4076             {
4077                 cancel_time      => undef,
4078                 fulfillment_time => undef,
4079                 hold_type        => 'T',
4080                 id               => $hold_ids
4081             },
4082             {
4083                 flesh        => 1,
4084                 flesh_fields => { ahr => ['usr'] }
4085             }
4086         ],
4087         { substream => 1 }
4088     );
4089
4090     for my $hold (@$holds) {
4091         $e->allowed('UPDATE_HOLD', $hold->usr->home_ou) or return $e->die_event;
4092         $logger->info("Changing hold " . $hold->id . " target from " . $hold->target . " to $new_bib_id in title hold target change");
4093         $hold->target( $new_bib_id );
4094         $e->update_action_hold_request($hold) or return $e->die_event;
4095     }
4096
4097     $e->commit;
4098
4099     _reset_hold($self, $e->requestor, $_) for @$holds;
4100
4101     return 1;
4102 }
4103
4104 __PACKAGE__->register_method(
4105     method    => 'rec_hold_count',
4106     api_name  => 'open-ils.circ.bre.holds.count',
4107     signature => {
4108         desc => q/Returns the total number of holds that target the
4109             selected bib record or its associated copies and call_numbers/,
4110         params => [
4111             { desc => 'Bib ID', type => 'number' },
4112             { desc => q/Optional arguments.  Supported arguments include:
4113                 "pickup_lib_descendant" -> limit holds to those whose pickup
4114                 library is equal to or is a child of the provided org unit/,
4115                 type => 'object'
4116             }
4117         ],
4118         return => {desc => 'Hold count', type => 'number'}
4119     }
4120 );
4121
4122 __PACKAGE__->register_method(
4123     method    => 'rec_hold_count',
4124     api_name  => 'open-ils.circ.mmr.holds.count',
4125     signature => {
4126         desc => q/Returns the total number of holds that target the
4127             selected metarecord or its associated copies, call_numbers, and bib records/,
4128         params => [
4129             { desc => 'Metarecord ID', type => 'number' },
4130         ],
4131         return => {desc => 'Hold count', type => 'number'}
4132     }
4133 );
4134
4135 # XXX Need to add type I holds to these counts
4136 sub rec_hold_count {
4137     my($self, $conn, $target_id, $args) = @_;
4138     $args ||= {};
4139
4140     my $mmr_join = {
4141         mmrsm => {
4142             field => 'source',
4143             fkey => 'id',
4144             filter => {metarecord => $target_id}
4145         }
4146     };
4147
4148     my $bre_join = {
4149         bre => {
4150             field => 'id',
4151             filter => { id => $target_id },
4152             fkey => 'record'
4153         }
4154     };
4155
4156     if($self->api_name =~ /mmr/) {
4157         delete $bre_join->{bre}->{filter};
4158         $bre_join->{bre}->{join} = $mmr_join;
4159     }
4160
4161     my $cn_join = {
4162         acn => {
4163             field => 'id',
4164             fkey => 'call_number',
4165             join => $bre_join
4166         }
4167     };
4168
4169     my $query = {
4170         select => {ahr => [{column => 'id', transform => 'count', alias => 'count'}]},
4171         from => 'ahr',
4172         where => {
4173             '+ahr' => {
4174                 cancel_time => undef,
4175                 fulfillment_time => undef,
4176                 '-or' => [
4177                     {
4178                         '-and' => {
4179                             hold_type => [qw/C F R/],
4180                             target => {
4181                                 in => {
4182                                     select => {acp => ['id']},
4183                                     from => { acp => $cn_join }
4184                                 }
4185                             }
4186                         }
4187                     },
4188                     {
4189                         '-and' => {
4190                             hold_type => 'V',
4191                             target => {
4192                                 in => {
4193                                     select => {acn => ['id']},
4194                                     from => {acn => $bre_join}
4195                                 }
4196                             }
4197                         }
4198                     },
4199                     {
4200                         '-and' => {
4201                             hold_type => 'P',
4202                             target => {
4203                                 in => {
4204                                     select => {bmp => ['id']},
4205                                     from => {bmp => $bre_join}
4206                                 }
4207                             }
4208                         }
4209                     },
4210                     {
4211                         '-and' => {
4212                             hold_type => 'T',
4213                             target => $target_id
4214                         }
4215                     }
4216                 ]
4217             }
4218         }
4219     };
4220
4221     if($self->api_name =~ /mmr/) {
4222         $query->{where}->{'+ahr'}->{'-or'}->[3] = {
4223             '-and' => {
4224                 hold_type => 'T',
4225                 target => {
4226                     in => {
4227                         select => {bre => ['id']},
4228                         from => {bre => $mmr_join}
4229                     }
4230                 }
4231             }
4232         };
4233
4234         $query->{where}->{'+ahr'}->{'-or'}->[4] = {
4235             '-and' => {
4236                 hold_type => 'M',
4237                 target => $target_id
4238             }
4239         };
4240     }
4241
4242
4243     if (my $pld = $args->{pickup_lib_descendant}) {
4244
4245         my $top_ou = new_editor()->search_actor_org_unit(
4246             {parent_ou => undef}
4247         )->[0]; # XXX Assumes single root node. Not alone in this...
4248
4249         $query->{where}->{'+ahr'}->{pickup_lib} = {
4250             in => {
4251                 select  => {aou => [{ 
4252                     column => 'id', 
4253                     transform => 'actor.org_unit_descendants', 
4254                     result_field => 'id' 
4255                 }]},
4256                 from    => 'aou',
4257                 where   => {id => $pld}
4258             }
4259         } if ($pld != $top_ou->id);
4260     }
4261
4262
4263     return new_editor()->json_query($query)->[0]->{count};
4264 }
4265
4266 # A helper function to calculate a hold's expiration time at a given
4267 # org_unit. Takes the org_unit as an argument and returns either the
4268 # hold expire time as an ISO8601 string or undef if there is no hold
4269 # expiration interval set for the subject ou.
4270 sub calculate_expire_time
4271 {
4272     my $ou = shift;
4273     my $interval = $U->ou_ancestor_setting_value($ou, OILS_SETTING_HOLD_EXPIRE);
4274     if($interval) {
4275         my $date = DateTime->now->add(seconds => OpenSRF::Utils::interval_to_seconds($interval));
4276         return $U->epoch2ISO8601($date->epoch);
4277     }
4278     return undef;
4279 }
4280
4281
4282 __PACKAGE__->register_method(
4283     method    => 'mr_hold_filter_attrs',
4284     api_name  => 'open-ils.circ.mmr.holds.filters',
4285     authoritative => 1,
4286     stream => 1,
4287     signature => {
4288         desc => q/
4289             Returns the set of available formats and languages for the
4290             constituent records of the provided metarcord.
4291             If an array of hold IDs is also provided, information about
4292             each is returned as well.  This information includes:
4293             1. a slightly easier to read version of holdable_formats
4294             2. attributes describing the set of format icons included
4295                in the set of desired, constituent records.
4296         /,
4297         params => [
4298             {desc => 'Metarecord ID', type => 'number'},
4299             {desc => 'Context Org ID', type => 'number'},
4300             {desc => 'Hold ID List', type => 'array'},
4301         ],
4302         return => {
4303             desc => q/
4304                 Stream of objects.  The first will have a 'metarecord' key
4305                 containing non-hold-specific metarecord information, subsequent
4306                 responses will contain a 'hold' key containing hold-specific
4307                 information
4308             /, 
4309             type => 'object'
4310         }
4311     }
4312 );
4313
4314 sub mr_hold_filter_attrs { 
4315     my ($self, $client, $mr_id, $org_id, $hold_ids) = @_;
4316     my $e = new_editor();
4317
4318     # by default, return MR / hold attributes for all constituent
4319     # records with holdable copies.  If there is a hard boundary,
4320     # though, limit to records with copies within the boundary,
4321     # since anything outside the boundary can never be held.
4322     my $org_depth = 0;
4323     if ($org_id) {
4324         $org_depth = $U->ou_ancestor_setting_value(
4325             $org_id, OILS_SETTING_HOLD_HARD_BOUNDARY) || 0;
4326     }
4327
4328     # get all org-scoped records w/ holdable copies for this metarecord
4329     my ($bre_ids) = $self->method_lookup(
4330         'open-ils.circ.holds.metarecord.filtered_records')->run(
4331             $mr_id, undef, $org_id, $org_depth);
4332
4333     my $item_lang_attr = 'item_lang'; # configurable?
4334     my $format_attr = $e->retrieve_config_global_flag(
4335         'opac.metarecord.holds.format_attr')->value;
4336
4337     # helper sub for fetching ccvms for a batch of record IDs
4338     sub get_batch_ccvms {
4339         my ($e, $attr, $bre_ids) = @_;
4340         return [] unless $bre_ids and @$bre_ids;
4341         my $vals = $e->search_metabib_record_attr_flat({
4342             attr => $attr,
4343             id => $bre_ids
4344         });
4345         return [] unless @$vals;
4346         return $e->search_config_coded_value_map({
4347             ctype => $attr,
4348             code => [map {$_->value} @$vals]
4349         });
4350     }
4351
4352     my $langs = get_batch_ccvms($e, $item_lang_attr, $bre_ids);
4353     my $formats = get_batch_ccvms($e, $format_attr, $bre_ids);
4354
4355     $client->respond({
4356         metarecord => {
4357             id => $mr_id,
4358             formats => $formats,
4359             langs => $langs
4360         }
4361     });
4362
4363     return unless $hold_ids;
4364     my $icon_attr = $e->retrieve_config_global_flag('opac.icon_attr');
4365     $icon_attr = $icon_attr ? $icon_attr->value : '';
4366
4367     for my $hold_id (@$hold_ids) {
4368         my $hold = $e->retrieve_action_hold_request($hold_id) 
4369             or return $e->event;
4370
4371         next unless $hold->hold_type eq 'M';
4372
4373         my $resp = {
4374             hold => {
4375                 id => $hold_id,
4376                 formats => [],
4377                 langs => []
4378             }
4379         };
4380
4381         # collect the ccvm's for the selected formats / language
4382         # (i.e. the holdable formats) on the MR.
4383         # this assumes a two-key structure for format / language,
4384         # though no assumption is made about the keys themselves.
4385         my $hformats = OpenSRF::Utils::JSON->JSON2perl($hold->holdable_formats);
4386         my $lang_vals = [];
4387         my $format_vals = [];
4388         for my $val (values %$hformats) {
4389             # val is either a single ccvm or an array of them
4390             $val = [$val] unless ref $val eq 'ARRAY';
4391             for my $node (@$val) {
4392                 push (@$lang_vals, $node->{_val})   
4393                     if $node->{_attr} eq $item_lang_attr; 
4394                 push (@$format_vals, $node->{_val})   
4395                     if $node->{_attr} eq $format_attr;
4396             }
4397         }
4398
4399         # fetch the ccvm's for consistency with the {metarecord} blob
4400         $resp->{hold}{formats} = $e->search_config_coded_value_map({
4401             ctype => $format_attr, code => $format_vals});
4402         $resp->{hold}{langs} = $e->search_config_coded_value_map({
4403             ctype => $item_lang_attr, code => $lang_vals});
4404
4405         # find all of the bib records within this metarcord whose 
4406         # format / language match the holdable formats on the hold
4407         my ($bre_ids) = $self->method_lookup(
4408             'open-ils.circ.holds.metarecord.filtered_records')->run(
4409                 $hold->target, $hold->holdable_formats, 
4410                 $hold->selection_ou, $hold->selection_depth);
4411
4412         # now find all of the 'icon' attributes for the records
4413         $resp->{hold}{icons} = get_batch_ccvms($e, $icon_attr, $bre_ids);
4414         $client->respond($resp);
4415     }
4416
4417     return;
4418 }
4419
4420 __PACKAGE__->register_method(
4421     method        => "copy_has_holds_count",
4422     api_name      => "open-ils.circ.copy.has_holds_count",
4423     authoritative => 1,
4424     signature     => {
4425         desc => q/
4426             Returns the number of holds a paticular copy has
4427         /,
4428         params => [
4429             { desc => 'Authentication Token', type => 'string'},
4430             { desc => 'Copy ID', type => 'number'}
4431         ],
4432         return => {
4433             desc => q/
4434                 Simple count value
4435             /,
4436             type => 'number'
4437         }
4438     }
4439 );
4440
4441 sub copy_has_holds_count {
4442     my( $self, $conn, $auth, $copyid ) = @_;
4443     my $e = new_editor(authtoken=>$auth);
4444     return $e->event unless $e->checkauth;
4445
4446     if( $copyid && $copyid > 0 ) {
4447         my $meth = 'retrieve_action_has_holds_count';
4448         my $data = $e->$meth($copyid);
4449         if($data){
4450                 return $data->count();
4451         }
4452     }
4453     return 0;
4454 }
4455
4456 1;