]> git.evergreen-ils.org Git - Evergreen.git/blob - Open-ILS/src/perlmods/lib/OpenILS/Application/Circ/Holds.pm
LP#1596595 Hold targeter refactoring and optimization.
[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_bundle_size') && $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}) if (!$client->can('max_bundle_size') && $client->can('max_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     $prox_cache{$home_org} =
2624         $e->search_actor_org_unit_proximity({from_org => $home_org})
2625         unless $prox_cache{$home_org};
2626     my $home_prox = $prox_cache{$home_org};
2627     $logger->info("prox cache $home_org " . $prox_cache{$home_org});
2628
2629     my %buckets;
2630     my %hash = map { ($_->to_org => $_->prox) } @$home_prox;
2631     push( @{$buckets{ $hash{$_->{circ_lib}} } }, $_->{id} ) for @$copies;
2632
2633     my @keys = sort { $a <=> $b } keys %buckets;
2634
2635
2636     if( $home_org ne $req_org ) {
2637       # -----------------------------------------------------------------------
2638       # shove the copies close to the request_lib into the primary buckets
2639       # directly before the farthest away copies.  That way, they are not
2640       # given priority, but they are checked before the farthest copies.
2641       # -----------------------------------------------------------------------
2642         $prox_cache{$req_org} =
2643             $e->search_actor_org_unit_proximity({from_org => $req_org})
2644             unless $prox_cache{$req_org};
2645         my $req_prox = $prox_cache{$req_org};
2646
2647         my %buckets2;
2648         my %hash2 = map { ($_->to_org => $_->prox) } @$req_prox;
2649         push( @{$buckets2{ $hash2{$_->{circ_lib}} } }, $_->{id} ) for @$copies;
2650
2651         my $highest_key = $keys[@keys - 1];  # the farthest prox in the exising buckets
2652         my $new_key = $highest_key - 0.5; # right before the farthest prox
2653         my @keys2   = sort { $a <=> $b } keys %buckets2;
2654         for my $key (@keys2) {
2655             last if $key >= $highest_key;
2656             push( @{$buckets{$new_key}}, $_ ) for @{$buckets2{$key}};
2657         }
2658     }
2659
2660     @keys = sort { $a <=> $b } keys %buckets;
2661
2662     my $title;
2663     my %seen;
2664     my @status;
2665     my $age_protect_only = 0;
2666     OUTER: for my $key (@keys) {
2667       my @cps = @{$buckets{$key}};
2668
2669       $logger->info("looking at " . scalar(@{$buckets{$key}}). " copies in proximity bucket $key");
2670
2671       for my $copyid (@cps) {
2672
2673          next if $seen{$copyid};
2674          $seen{$copyid} = 1; # there could be dupes given the merged buckets
2675          my $copy = $e->retrieve_asset_copy($copyid);
2676          $logger->debug("looking at bucket_key=$key, copy $copyid : circ_lib = " . $copy->circ_lib);
2677
2678          unless($title) { # grab the title if we don't already have it
2679             my $vol = $e->retrieve_asset_call_number(
2680                [ $copy->call_number, { flesh => 1, flesh_fields => { bre => ['fixed_fields'], acn => ['record'] } } ] );
2681             $title = $vol->record;
2682          }
2683
2684          @status = verify_copy_for_hold(
2685             $patron, $requestor, $title, $copy, $pickup_lib, $request_lib, $oargs);
2686
2687          $age_protect_only ||= $status[3];
2688          last OUTER if $status[0];
2689       }
2690     }
2691
2692     $status[3] = $age_protect_only;
2693     return @status;
2694 }
2695
2696 sub _check_issuance_hold_is_possible {
2697     my( $issuanceid, $depth, $request_lib, $patron, $requestor, $pickup_lib, $selection_ou, $oargs ) = @_;
2698
2699     my $e = new_editor();
2700     my %org_filter = create_ranged_org_filter($e, $selection_ou, $depth);
2701
2702     # this monster will grab the id and circ_lib of all of the "holdable" copies for the given record
2703     my $copies = $e->json_query(
2704         {
2705             select => { acp => ['id', 'circ_lib'] },
2706               from => {
2707                 acp => {
2708                     sitem => {
2709                         field  => 'unit',
2710                         fkey   => 'id',
2711                         filter => { issuance => $issuanceid }
2712                     },
2713                     acpl => {
2714                         field => 'id',
2715                         filter => { holdable => 't', deleted => 'f' },
2716                         fkey => 'location'
2717                     },
2718                     ccs  => { field => 'id', filter => { holdable => 't'}, fkey => 'status'   }
2719                 }
2720             },
2721             where => {
2722                 '+acp' => { circulate => 't', deleted => 'f', holdable => 't', %org_filter }
2723             },
2724             distinct => 1
2725         }
2726     );
2727
2728     $logger->info("issuance possible found ".scalar(@$copies)." potential copies");
2729
2730     my $empty_ok;
2731     if (!@$copies) {
2732         $empty_ok = $e->retrieve_config_global_flag('circ.holds.empty_issuance_ok');
2733         $empty_ok = ($empty_ok and $U->is_true($empty_ok->enabled));
2734
2735         return (
2736             0, 0, [
2737                 new OpenILS::Event(
2738                     "HIGH_LEVEL_HOLD_HAS_NO_COPIES",
2739                     "payload" => {"fail_part" => "no_ultimate_items"}
2740                 )
2741             ]
2742         ) unless $empty_ok;
2743
2744         return (1, 0);
2745     }
2746
2747     # -----------------------------------------------------------------------
2748     # sort the copies into buckets based on their circ_lib proximity to
2749     # the patron's home_ou.
2750     # -----------------------------------------------------------------------
2751
2752     my $home_org = $patron->home_ou;
2753     my $req_org = $request_lib->id;
2754
2755     $prox_cache{$home_org} =
2756         $e->search_actor_org_unit_proximity({from_org => $home_org})
2757         unless $prox_cache{$home_org};
2758     my $home_prox = $prox_cache{$home_org};
2759     $logger->info("prox cache $home_org " . $prox_cache{$home_org});
2760
2761     my %buckets;
2762     my %hash = map { ($_->to_org => $_->prox) } @$home_prox;
2763     push( @{$buckets{ $hash{$_->{circ_lib}} } }, $_->{id} ) for @$copies;
2764
2765     my @keys = sort { $a <=> $b } keys %buckets;
2766
2767
2768     if( $home_org ne $req_org ) {
2769       # -----------------------------------------------------------------------
2770       # shove the copies close to the request_lib into the primary buckets
2771       # directly before the farthest away copies.  That way, they are not
2772       # given priority, but they are checked before the farthest copies.
2773       # -----------------------------------------------------------------------
2774         $prox_cache{$req_org} =
2775             $e->search_actor_org_unit_proximity({from_org => $req_org})
2776             unless $prox_cache{$req_org};
2777         my $req_prox = $prox_cache{$req_org};
2778
2779         my %buckets2;
2780         my %hash2 = map { ($_->to_org => $_->prox) } @$req_prox;
2781         push( @{$buckets2{ $hash2{$_->{circ_lib}} } }, $_->{id} ) for @$copies;
2782
2783         my $highest_key = $keys[@keys - 1];  # the farthest prox in the exising buckets
2784         my $new_key = $highest_key - 0.5; # right before the farthest prox
2785         my @keys2   = sort { $a <=> $b } keys %buckets2;
2786         for my $key (@keys2) {
2787             last if $key >= $highest_key;
2788             push( @{$buckets{$new_key}}, $_ ) for @{$buckets2{$key}};
2789         }
2790     }
2791
2792     @keys = sort { $a <=> $b } keys %buckets;
2793
2794     my $title;
2795     my %seen;
2796     my @status;
2797     my $age_protect_only = 0;
2798     OUTER: for my $key (@keys) {
2799       my @cps = @{$buckets{$key}};
2800
2801       $logger->info("looking at " . scalar(@{$buckets{$key}}). " copies in proximity bucket $key");
2802
2803       for my $copyid (@cps) {
2804
2805          next if $seen{$copyid};
2806          $seen{$copyid} = 1; # there could be dupes given the merged buckets
2807          my $copy = $e->retrieve_asset_copy($copyid);
2808          $logger->debug("looking at bucket_key=$key, copy $copyid : circ_lib = " . $copy->circ_lib);
2809
2810          unless($title) { # grab the title if we don't already have it
2811             my $vol = $e->retrieve_asset_call_number(
2812                [ $copy->call_number, { flesh => 1, flesh_fields => { bre => ['fixed_fields'], acn => ['record'] } } ] );
2813             $title = $vol->record;
2814          }
2815
2816          @status = verify_copy_for_hold(
2817             $patron, $requestor, $title, $copy, $pickup_lib, $request_lib, $oargs);
2818
2819          $age_protect_only ||= $status[3];
2820          last OUTER if $status[0];
2821       }
2822     }
2823
2824     if (!$status[0]) {
2825         if (!defined($empty_ok)) {
2826             $empty_ok = $e->retrieve_config_global_flag('circ.holds.empty_issuance_ok');
2827             $empty_ok = ($empty_ok and $U->is_true($empty_ok->enabled));
2828         }
2829
2830         return (1,0) if ($empty_ok);
2831     }
2832     $status[3] = $age_protect_only;
2833     return @status;
2834 }
2835
2836 sub _check_monopart_hold_is_possible {
2837     my( $partid, $depth, $request_lib, $patron, $requestor, $pickup_lib, $selection_ou, $oargs ) = @_;
2838
2839     my $e = new_editor();
2840     my %org_filter = create_ranged_org_filter($e, $selection_ou, $depth);
2841
2842     # this monster will grab the id and circ_lib of all of the "holdable" copies for the given record
2843     my $copies = $e->json_query(
2844         {
2845             select => { acp => ['id', 'circ_lib'] },
2846               from => {
2847                 acp => {
2848                     acpm => {
2849                         field  => 'target_copy',
2850                         fkey   => 'id',
2851                         filter => { part => $partid }
2852                     },
2853                     acpl => {
2854                         field => 'id',
2855                         filter => { holdable => 't', deleted => 'f' },
2856                         fkey => 'location'
2857                     },
2858                     ccs  => { field => 'id', filter => { holdable => 't'}, fkey => 'status'   }
2859                 }
2860             },
2861             where => {
2862                 '+acp' => { circulate => 't', deleted => 'f', holdable => 't', %org_filter }
2863             },
2864             distinct => 1
2865         }
2866     );
2867
2868     $logger->info("monopart possible found ".scalar(@$copies)." potential copies");
2869
2870     my $empty_ok;
2871     if (!@$copies) {
2872         $empty_ok = $e->retrieve_config_global_flag('circ.holds.empty_part_ok');
2873         $empty_ok = ($empty_ok and $U->is_true($empty_ok->enabled));
2874
2875         return (
2876             0, 0, [
2877                 new OpenILS::Event(
2878                     "HIGH_LEVEL_HOLD_HAS_NO_COPIES",
2879                     "payload" => {"fail_part" => "no_ultimate_items"}
2880                 )
2881             ]
2882         ) unless $empty_ok;
2883
2884         return (1, 0);
2885     }
2886
2887     # -----------------------------------------------------------------------
2888     # sort the copies into buckets based on their circ_lib proximity to
2889     # the patron's home_ou.
2890     # -----------------------------------------------------------------------
2891
2892     my $home_org = $patron->home_ou;
2893     my $req_org = $request_lib->id;
2894
2895     $prox_cache{$home_org} =
2896         $e->search_actor_org_unit_proximity({from_org => $home_org})
2897         unless $prox_cache{$home_org};
2898     my $home_prox = $prox_cache{$home_org};
2899     $logger->info("prox cache $home_org " . $prox_cache{$home_org});
2900
2901     my %buckets;
2902     my %hash = map { ($_->to_org => $_->prox) } @$home_prox;
2903     push( @{$buckets{ $hash{$_->{circ_lib}} } }, $_->{id} ) for @$copies;
2904
2905     my @keys = sort { $a <=> $b } keys %buckets;
2906
2907
2908     if( $home_org ne $req_org ) {
2909       # -----------------------------------------------------------------------
2910       # shove the copies close to the request_lib into the primary buckets
2911       # directly before the farthest away copies.  That way, they are not
2912       # given priority, but they are checked before the farthest copies.
2913       # -----------------------------------------------------------------------
2914         $prox_cache{$req_org} =
2915             $e->search_actor_org_unit_proximity({from_org => $req_org})
2916             unless $prox_cache{$req_org};
2917         my $req_prox = $prox_cache{$req_org};
2918
2919         my %buckets2;
2920         my %hash2 = map { ($_->to_org => $_->prox) } @$req_prox;
2921         push( @{$buckets2{ $hash2{$_->{circ_lib}} } }, $_->{id} ) for @$copies;
2922
2923         my $highest_key = $keys[@keys - 1];  # the farthest prox in the exising buckets
2924         my $new_key = $highest_key - 0.5; # right before the farthest prox
2925         my @keys2   = sort { $a <=> $b } keys %buckets2;
2926         for my $key (@keys2) {
2927             last if $key >= $highest_key;
2928             push( @{$buckets{$new_key}}, $_ ) for @{$buckets2{$key}};
2929         }
2930     }
2931
2932     @keys = sort { $a <=> $b } keys %buckets;
2933
2934     my $title;
2935     my %seen;
2936     my @status;
2937     my $age_protect_only = 0;
2938     OUTER: for my $key (@keys) {
2939       my @cps = @{$buckets{$key}};
2940
2941       $logger->info("looking at " . scalar(@{$buckets{$key}}). " copies in proximity bucket $key");
2942
2943       for my $copyid (@cps) {
2944
2945          next if $seen{$copyid};
2946          $seen{$copyid} = 1; # there could be dupes given the merged buckets
2947          my $copy = $e->retrieve_asset_copy($copyid);
2948          $logger->debug("looking at bucket_key=$key, copy $copyid : circ_lib = " . $copy->circ_lib);
2949
2950          unless($title) { # grab the title if we don't already have it
2951             my $vol = $e->retrieve_asset_call_number(
2952                [ $copy->call_number, { flesh => 1, flesh_fields => { bre => ['fixed_fields'], acn => ['record'] } } ] );
2953             $title = $vol->record;
2954          }
2955
2956          @status = verify_copy_for_hold(
2957             $patron, $requestor, $title, $copy, $pickup_lib, $request_lib, $oargs);
2958
2959          $age_protect_only ||= $status[3];
2960          last OUTER if $status[0];
2961       }
2962     }
2963
2964     if (!$status[0]) {
2965         if (!defined($empty_ok)) {
2966             $empty_ok = $e->retrieve_config_global_flag('circ.holds.empty_part_ok');
2967             $empty_ok = ($empty_ok and $U->is_true($empty_ok->enabled));
2968         }
2969
2970         return (1,0) if ($empty_ok);
2971     }
2972     $status[3] = $age_protect_only;
2973     return @status;
2974 }
2975
2976
2977 sub _check_volume_hold_is_possible {
2978     my( $vol, $title, $depth, $request_lib, $patron, $requestor, $pickup_lib, $selection_ou, $oargs ) = @_;
2979     my %org_filter = create_ranged_org_filter(new_editor(), $selection_ou, $depth);
2980     my $copies = new_editor->search_asset_copy({call_number => $vol->id, %org_filter});
2981     $logger->info("checking possibility of volume hold for volume ".$vol->id);
2982
2983     my $filter_copies = [];
2984     for my $copy (@$copies) {
2985         # ignore part-mapped copies for regular volume level holds
2986         push(@$filter_copies, $copy) unless
2987             new_editor->search_asset_copy_part_map({target_copy => $copy->id})->[0];
2988     }
2989     $copies = $filter_copies;
2990
2991     return (
2992         0, 0, [
2993             new OpenILS::Event(
2994                 "HIGH_LEVEL_HOLD_HAS_NO_COPIES",
2995                 "payload" => {"fail_part" => "no_ultimate_items"}
2996             )
2997         ]
2998     ) unless @$copies;
2999
3000     my @status;
3001     my $age_protect_only = 0;
3002     for my $copy ( @$copies ) {
3003         @status = verify_copy_for_hold(
3004             $patron, $requestor, $title, $copy, $pickup_lib, $request_lib, $oargs );
3005         $age_protect_only ||= $status[3];
3006         last if $status[0];
3007     }
3008     $status[3] = $age_protect_only;
3009     return @status;
3010 }
3011
3012
3013
3014 sub verify_copy_for_hold {
3015     my( $patron, $requestor, $title, $copy, $pickup_lib, $request_lib, $oargs ) = @_;
3016     # $oargs should be undef unless we're overriding.
3017     $logger->info("checking possibility of copy in hold request for copy ".$copy->id);
3018     my $permitted = OpenILS::Utils::PermitHold::permit_copy_hold(
3019         {
3020             patron           => $patron,
3021             requestor        => $requestor,
3022             copy             => $copy,
3023             title            => $title,
3024             title_descriptor => $title->fixed_fields,
3025             pickup_lib       => $pickup_lib,
3026             request_lib      => $request_lib,
3027             new_hold         => 1,
3028             show_event_list  => 1
3029         }
3030     );
3031
3032     # Check for override permissions on events.
3033     if ($oargs && $permitted && scalar @$permitted) {
3034         # Remove the events from permitted that we can override.
3035         if ($oargs->{events}) {
3036             foreach my $evt (@{$oargs->{events}}) {
3037                 $permitted = [grep {$_->{textcode} ne $evt} @{$permitted}];
3038             }
3039         }
3040         # Now, we handle the override all case by checking remaining
3041         # events against override permissions.
3042         if (scalar @$permitted && $oargs->{all}) {
3043             # Pre-set events and failed members of oargs to empty
3044             # arrays, if they are not set, yet.
3045             $oargs->{events} = [] unless ($oargs->{events});
3046             $oargs->{failed} = [] unless ($oargs->{failed});
3047             # When we're done with these checks, we swap permitted
3048             # with a reference to @disallowed.
3049             my @disallowed = ();
3050             foreach my $evt (@{$permitted}) {
3051                 # Check if we've already seen the event in this
3052                 # session and it failed.
3053                 if (grep {$_ eq $evt->{textcode}} @{$oargs->{failed}}) {
3054                     push(@disallowed, $evt);
3055                 } else {
3056                     # We have to check if the requestor has the
3057                     # override permission.
3058
3059                     # AppUtils::check_user_perms returns the perm if
3060                     # the user doesn't have it, undef if they do.
3061                     if ($apputils->check_user_perms($requestor->id, $requestor->ws_ou, $evt->{textcode} . '.override')) {
3062                         push(@disallowed, $evt);
3063                         push(@{$oargs->{failed}}, $evt->{textcode});
3064                     } else {
3065                         push(@{$oargs->{events}}, $evt->{textcode});
3066                     }
3067                 }
3068             }
3069             $permitted = \@disallowed;
3070         }
3071     }
3072
3073     my $age_protect_only = 0;
3074     if (@$permitted == 1 && @$permitted[0]->{textcode} eq 'ITEM_AGE_PROTECTED') {
3075         $age_protect_only = 1;
3076     }
3077
3078     return (
3079         (not scalar @$permitted), # true if permitted is an empty arrayref
3080         (   # XXX This test is of very dubious value; someone should figure
3081             # out what if anything is checking this value
3082             ($copy->circ_lib == $pickup_lib) and
3083             ($copy->status == OILS_COPY_STATUS_AVAILABLE)
3084         ),
3085         $permitted,
3086         $age_protect_only
3087     );
3088 }
3089
3090
3091
3092 sub find_nearest_permitted_hold {
3093
3094     my $class  = shift;
3095     my $editor = shift;     # CStoreEditor object
3096     my $copy   = shift;     # copy to target
3097     my $user   = shift;     # staff
3098     my $check_only = shift; # do no updates, just see if the copy could fulfill a hold
3099
3100     my $evt = OpenILS::Event->new('ACTION_HOLD_REQUEST_NOT_FOUND');
3101
3102     my $bc = $copy->barcode;
3103
3104     # find any existing holds that already target this copy
3105     my $old_holds = $editor->search_action_hold_request(
3106         {    current_copy => $copy->id,
3107             cancel_time  => undef,
3108             capture_time => undef
3109         }
3110     );
3111
3112     my $hold_stall_interval = $U->ou_ancestor_setting_value($user->ws_ou, OILS_SETTING_HOLD_SOFT_STALL);
3113
3114     $logger->info("circulator: searching for best hold at org ".$user->ws_ou.
3115         " and copy $bc with a hold stalling interval of ". ($hold_stall_interval || "(none)"));
3116
3117     my $fifo = $U->ou_ancestor_setting_value($user->ws_ou, 'circ.holds_fifo');
3118
3119     # the nearest_hold API call now needs this
3120     $copy->call_number($editor->retrieve_asset_call_number($copy->call_number))
3121         unless ref $copy->call_number;
3122
3123     # search for what should be the best holds for this copy to fulfill
3124     my $best_holds = $U->storagereq(
3125         "open-ils.storage.action.hold_request.nearest_hold.atomic", 
3126         $user->ws_ou, $copy, 100, $hold_stall_interval, $fifo );
3127
3128     # Add any pre-targeted holds to the list too? Unless they are already there, anyway.
3129     if ($old_holds) {
3130         for my $holdid (@$old_holds) {
3131             next unless $holdid;
3132             push(@$best_holds, $holdid) unless ( grep { ''.$holdid eq ''.$_ } @$best_holds );
3133         }
3134     }
3135
3136     unless(@$best_holds) {
3137         $logger->info("circulator: no suitable holds found for copy $bc");
3138         return (undef, $evt);
3139     }
3140
3141
3142     my $best_hold;
3143
3144     # for each potential hold, we have to run the permit script
3145     # to make sure the hold is actually permitted.
3146     my %reqr_cache;
3147     my %org_cache;
3148     for my $holdid (@$best_holds) {
3149         next unless $holdid;
3150         $logger->info("circulator: checking if hold $holdid is permitted for copy $bc");
3151
3152         my $hold = $editor->retrieve_action_hold_request($holdid) or next;
3153         # Force and recall holds bypass all rules
3154         if ($hold->hold_type eq 'R' || $hold->hold_type eq 'F') {
3155             $best_hold = $hold;
3156             last;
3157         }
3158         my $reqr = $reqr_cache{$hold->requestor} || $editor->retrieve_actor_user($hold->requestor);
3159         my $rlib = $org_cache{$hold->request_lib} || $editor->retrieve_actor_org_unit($hold->request_lib);
3160
3161         $reqr_cache{$hold->requestor} = $reqr;
3162         $org_cache{$hold->request_lib} = $rlib;
3163
3164         # see if this hold is permitted
3165         my $permitted = OpenILS::Utils::PermitHold::permit_copy_hold(
3166             {
3167                 patron_id   => $hold->usr,
3168                 requestor   => $reqr,
3169                 copy        => $copy,
3170                 pickup_lib  => $hold->pickup_lib,
3171                 request_lib => $rlib,
3172                 retarget    => 1
3173             }
3174         );
3175
3176         if( $permitted ) {
3177             $best_hold = $hold;
3178             last;
3179         }
3180     }
3181
3182
3183     unless( $best_hold ) { # no "good" permitted holds were found
3184         # we got nuthin
3185         $logger->info("circulator: no suitable holds found for copy $bc");
3186         return (undef, $evt);
3187     }
3188
3189     $logger->info("circulator: best hold ".$best_hold->id." found for copy $bc");
3190
3191     # indicate a permitted hold was found
3192     return $best_hold if $check_only;
3193
3194     # we've found a permitted hold.  we need to "grab" the copy
3195     # to prevent re-targeted holds (next part) from re-grabbing the copy
3196     $best_hold->current_copy($copy->id);
3197     $editor->update_action_hold_request($best_hold)
3198         or return (undef, $editor->event);
3199
3200
3201     my @retarget;
3202
3203     # re-target any other holds that already target this copy
3204     for my $old_hold (@$old_holds) {
3205         next if $old_hold->id eq $best_hold->id; # don't re-target the hold we want
3206         $logger->info("circulator: clearing current_copy and prev_check_time on hold ".
3207             $old_hold->id." after a better hold [".$best_hold->id."] was found");
3208         $old_hold->clear_current_copy;
3209         $old_hold->clear_prev_check_time;
3210         $editor->update_action_hold_request($old_hold)
3211             or return (undef, $editor->event);
3212         push(@retarget, $old_hold->id);
3213     }
3214
3215     return ($best_hold, undef, (@retarget) ? \@retarget : undef);
3216 }
3217
3218
3219
3220
3221
3222
3223 __PACKAGE__->register_method(
3224     method   => 'all_rec_holds',
3225     api_name => 'open-ils.circ.holds.retrieve_all_from_title',
3226 );
3227
3228 sub all_rec_holds {
3229     my( $self, $conn, $auth, $title_id, $args ) = @_;
3230
3231     my $e = new_editor(authtoken=>$auth);
3232     $e->checkauth or return $e->event;
3233     $e->allowed('VIEW_HOLD') or return $e->event;
3234
3235     $args ||= {};
3236     $args->{fulfillment_time} = undef; #  we don't want to see old fulfilled holds
3237     $args->{cancel_time} = undef;
3238
3239     my $resp = {
3240           metarecord_holds => []
3241         , title_holds      => []
3242         , volume_holds     => []
3243         , copy_holds       => []
3244         , recall_holds     => []
3245         , force_holds      => []
3246         , part_holds       => []
3247         , issuance_holds   => []
3248     };
3249
3250     my $mr_map = $e->search_metabib_metarecord_source_map({source => $title_id})->[0];
3251     if($mr_map) {
3252         $resp->{metarecord_holds} = $e->search_action_hold_request(
3253             {   hold_type => OILS_HOLD_TYPE_METARECORD,
3254                 target => $mr_map->metarecord,
3255                 %$args
3256             }, {idlist => 1}
3257         );
3258     }
3259
3260     $resp->{title_holds} = $e->search_action_hold_request(
3261         {
3262             hold_type => OILS_HOLD_TYPE_TITLE,
3263             target => $title_id,
3264             %$args
3265         }, {idlist=>1} );
3266
3267     my $parts = $e->search_biblio_monograph_part(
3268         {
3269             record => $title_id
3270         }, {idlist=>1} );
3271
3272     if (@$parts) {
3273         $resp->{part_holds} = $e->search_action_hold_request(
3274             {
3275                 hold_type => OILS_HOLD_TYPE_MONOPART,
3276                 target => $parts,
3277                 %$args
3278             }, {idlist=>1} );
3279     }
3280
3281     my $subs = $e->search_serial_subscription(
3282         { record_entry => $title_id }, {idlist=>1});
3283
3284     if (@$subs) {
3285         my $issuances = $e->search_serial_issuance(
3286             {subscription => $subs}, {idlist=>1}
3287         );
3288
3289         if (@$issuances) {
3290             $resp->{issuance_holds} = $e->search_action_hold_request(
3291                 {
3292                     hold_type => OILS_HOLD_TYPE_ISSUANCE,
3293                     target => $issuances,
3294                     %$args
3295                 }, {idlist=>1}
3296             );
3297         }
3298     }
3299
3300     my $vols = $e->search_asset_call_number(
3301         { record => $title_id, deleted => 'f' }, {idlist=>1});
3302
3303     return $resp unless @$vols;
3304
3305     $resp->{volume_holds} = $e->search_action_hold_request(
3306         {
3307             hold_type => OILS_HOLD_TYPE_VOLUME,
3308             target => $vols,
3309             %$args },
3310         {idlist=>1} );
3311
3312     my $copies = $e->search_asset_copy(
3313         { call_number => $vols, deleted => 'f' }, {idlist=>1});
3314
3315     return $resp unless @$copies;
3316
3317     $resp->{copy_holds} = $e->search_action_hold_request(
3318         {
3319             hold_type => OILS_HOLD_TYPE_COPY,
3320             target => $copies,
3321             %$args },
3322         {idlist=>1} );
3323
3324     $resp->{recall_holds} = $e->search_action_hold_request(
3325         {
3326             hold_type => OILS_HOLD_TYPE_RECALL,
3327             target => $copies,
3328             %$args },
3329         {idlist=>1} );
3330
3331     $resp->{force_holds} = $e->search_action_hold_request(
3332         {
3333             hold_type => OILS_HOLD_TYPE_FORCE,
3334             target => $copies,
3335             %$args },
3336         {idlist=>1} );
3337
3338     return $resp;
3339 }
3340
3341
3342
3343
3344
3345 __PACKAGE__->register_method(
3346     method        => 'uber_hold',
3347     authoritative => 1,
3348     api_name      => 'open-ils.circ.hold.details.retrieve'
3349 );
3350
3351 sub uber_hold {
3352     my($self, $client, $auth, $hold_id, $args) = @_;
3353     my $e = new_editor(authtoken=>$auth);
3354     $e->checkauth or return $e->event;
3355     return uber_hold_impl($e, $hold_id, $args);
3356 }
3357
3358 __PACKAGE__->register_method(
3359     method        => 'batch_uber_hold',
3360     authoritative => 1,
3361     stream        => 1,
3362     api_name      => 'open-ils.circ.hold.details.batch.retrieve'
3363 );
3364
3365 sub batch_uber_hold {
3366     my($self, $client, $auth, $hold_ids, $args) = @_;
3367     my $e = new_editor(authtoken=>$auth);
3368     $e->checkauth or return $e->event;
3369     $client->respond(uber_hold_impl($e, $_, $args)) for @$hold_ids;
3370     return undef;
3371 }
3372
3373 sub uber_hold_impl {
3374     my($e, $hold_id, $args) = @_;
3375     $args ||= {};
3376
3377     my $hold = $e->retrieve_action_hold_request(
3378         [
3379             $hold_id,
3380             {
3381                 flesh => 1,
3382                 flesh_fields => { ahr => [ 'current_copy', 'usr', 'notes' ] }
3383             }
3384         ]
3385     ) or return $e->event;
3386
3387     if($hold->usr->id ne $e->requestor->id) {
3388         # caller is asking for someone else's hold
3389         $e->allowed('VIEW_HOLD') or return $e->event;
3390         $hold->notes( # filter out any non-staff ("private") notes (unless marked as public)
3391             [ grep { $U->is_true($_->staff) or $U->is_true($_->pub) } @{$hold->notes} ] );
3392
3393     } else {
3394         # caller is asking for own hold, but may not have permission to view staff notes
3395         unless($e->allowed('VIEW_HOLD')) {
3396             $hold->notes( # filter out any staff notes (unless marked as public)
3397                 [ grep { !$U->is_true($_->staff) or $U->is_true($_->pub) } @{$hold->notes} ] );
3398         }
3399     }
3400
3401     my $user = $hold->usr;
3402     $hold->usr($user->id);
3403
3404
3405     my( $mvr, $volume, $copy, $issuance, $part, $bre ) = find_hold_mvr($e, $hold, $args->{suppress_mvr});
3406
3407     flesh_hold_notices([$hold], $e) unless $args->{suppress_notices};
3408     flesh_hold_transits([$hold]) unless $args->{suppress_transits};
3409
3410     my $details = retrieve_hold_queue_status_impl($e, $hold);
3411
3412     my $resp = {
3413         hold    => $hold,
3414         bre_id  => $bre->id,
3415         ($copy     ? (copy           => $copy)     : ()),
3416         ($volume   ? (volume         => $volume)   : ()),
3417         ($issuance ? (issuance       => $issuance) : ()),
3418         ($part     ? (part           => $part)     : ()),
3419         ($args->{include_bre}  ?  (bre => $bre)    : ()),
3420         ($args->{suppress_mvr} ?  () : (mvr => $mvr)),
3421         %$details
3422     };
3423
3424     $resp->{copy}->location(
3425         $e->retrieve_asset_copy_location($resp->{copy}->location))
3426         if $resp->{copy} and $args->{flesh_acpl};
3427
3428     unless($args->{suppress_patron_details}) {
3429         my $card = $e->retrieve_actor_card($user->card) or return $e->event;
3430         $resp->{patron_first}   = $user->first_given_name,
3431         $resp->{patron_last}    = $user->family_name,
3432         $resp->{patron_barcode} = $card->barcode,
3433         $resp->{patron_alias}   = $user->alias,
3434     };
3435
3436     return $resp;
3437 }
3438
3439
3440
3441 # -----------------------------------------------------
3442 # Returns the MVR object that represents what the
3443 # hold is all about
3444 # -----------------------------------------------------
3445 sub find_hold_mvr {
3446     my( $e, $hold, $no_mvr ) = @_;
3447
3448     my $tid;
3449     my $copy;
3450     my $volume;
3451     my $issuance;
3452     my $part;
3453
3454     if( $hold->hold_type eq OILS_HOLD_TYPE_METARECORD ) {
3455         my $mr = $e->retrieve_metabib_metarecord($hold->target)
3456             or return $e->event;
3457         $tid = $mr->master_record;
3458
3459     } elsif( $hold->hold_type eq OILS_HOLD_TYPE_TITLE ) {
3460         $tid = $hold->target;
3461
3462     } elsif( $hold->hold_type eq OILS_HOLD_TYPE_VOLUME ) {
3463         $volume = $e->retrieve_asset_call_number($hold->target)
3464             or return $e->event;
3465         $tid = $volume->record;
3466
3467     } elsif( $hold->hold_type eq OILS_HOLD_TYPE_ISSUANCE ) {
3468         $issuance = $e->retrieve_serial_issuance([
3469             $hold->target,
3470             {flesh => 1, flesh_fields => {siss => [ qw/subscription/ ]}}
3471         ]) or return $e->event;
3472
3473         $tid = $issuance->subscription->record_entry;
3474
3475     } elsif( $hold->hold_type eq OILS_HOLD_TYPE_MONOPART ) {
3476         $part = $e->retrieve_biblio_monograph_part([
3477             $hold->target
3478         ]) or return $e->event;
3479
3480         $tid = $part->record;
3481
3482     } 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 ) {
3483         $copy = $e->retrieve_asset_copy([
3484             $hold->target,
3485             {flesh => 1, flesh_fields => {acp => ['call_number']}}
3486         ]) or return $e->event;
3487
3488         $volume = $copy->call_number;
3489         $tid = $volume->record;
3490     }
3491
3492     if(!$copy and ref $hold->current_copy ) {
3493         $copy = $hold->current_copy;
3494         $hold->current_copy($copy->id);
3495     }
3496
3497     if(!$volume and $copy) {
3498         $volume = $e->retrieve_asset_call_number($copy->call_number);
3499     }
3500
3501     # TODO return metarcord mvr for M holds
3502     my $title = $e->retrieve_biblio_record_entry($tid);
3503     return ( ($no_mvr) ? undef : $U->record_to_mvr($title), $volume, $copy, $issuance, $part, $title );
3504 }
3505
3506 __PACKAGE__->register_method(
3507     method    => 'clear_shelf_cache',
3508     api_name  => 'open-ils.circ.hold.clear_shelf.get_cache',
3509     stream    => 1,
3510     signature => {
3511         desc => q/
3512             Returns the holds processed with the given cache key
3513         /
3514     }
3515 );
3516
3517 sub clear_shelf_cache {
3518     my($self, $client, $auth, $cache_key, $chunk_size) = @_;
3519     my $e = new_editor(authtoken => $auth, xact => 1);
3520     return $e->die_event unless $e->checkauth and $e->allowed('VIEW_HOLD');
3521
3522     $chunk_size ||= 25;
3523     $client->max_chunk_size($chunk_size) if (!$client->can('max_bundle_size') && $client->can('max_chunk_size'));
3524
3525     my $hold_data = OpenSRF::Utils::Cache->new('global')->get_cache($cache_key);
3526
3527     if (!$hold_data) {
3528         $logger->info("no hold data found in cache"); # XXX TODO return event
3529         $e->rollback;
3530         return undef;
3531     }
3532
3533     my $maximum = 0;
3534     foreach (keys %$hold_data) {
3535         $maximum += scalar(@{ $hold_data->{$_} });
3536     }
3537     $client->respond({"maximum" => $maximum, "progress" => 0});
3538
3539     for my $action (sort keys %$hold_data) {
3540         while (@{$hold_data->{$action}}) {
3541             my @hid_chunk = splice @{$hold_data->{$action}}, 0, $chunk_size;
3542
3543             my $result_chunk = $e->json_query({
3544                 "select" => {
3545                     "acp" => ["barcode"],
3546                     "au" => [qw/
3547                         first_given_name second_given_name family_name alias
3548                     /],
3549                     "acn" => ["label"],
3550                     "acnp" => [{column => "label", alias => "prefix"}],
3551                     "acns" => [{column => "label", alias => "suffix"}],
3552                     "bre" => ["marc"],
3553                     "acpl" => ["name"],
3554                     "ahr" => ["id"]
3555                 },
3556                 "from" => {
3557                     "ahr" => {
3558                         "acp" => {
3559                             "field" => "id", "fkey" => "current_copy",
3560                             "join" => {
3561                                 "acn" => {
3562                                     "field" => "id", "fkey" => "call_number",
3563                                     "join" => {
3564                                         "bre" => {
3565                                             "field" => "id", "fkey" => "record"
3566                                         },
3567                                         "acnp" => {
3568                                             "field" => "id", "fkey" => "prefix"
3569                                         },
3570                                         "acns" => {
3571                                             "field" => "id", "fkey" => "suffix"
3572                                         }
3573                                     }
3574                                 },
3575                                 "acpl" => {"field" => "id", "fkey" => "location"}
3576                             }
3577                         },
3578                         "au" => {"field" => "id", "fkey" => "usr"}
3579                     }
3580                 },
3581                 "where" => {"+ahr" => {"id" => \@hid_chunk}}
3582             }, {"substream" => 1}) or return $e->die_event;
3583
3584             $client->respond([
3585                 map {
3586                     +{"action" => $action, "hold_details" => $_}
3587                 } @$result_chunk
3588             ]);
3589         }
3590     }
3591
3592     $e->rollback;
3593     return undef;
3594 }
3595
3596
3597 __PACKAGE__->register_method(
3598     method    => 'clear_shelf_process',
3599     stream    => 1,
3600     api_name  => 'open-ils.circ.hold.clear_shelf.process',
3601     signature => {
3602         desc => q/
3603             1. Find all holds that have expired on the holds shelf
3604             2. Cancel the holds
3605             3. If a clear-shelf status is configured, put targeted copies into this status
3606             4. Divide copies into 3 groups: items to transit, items to reshelve, and items
3607                 that are needed for holds.  No subsequent action is taken on the holds
3608                 or items after grouping.
3609         /
3610     }
3611 );
3612
3613 sub clear_shelf_process {
3614     my($self, $client, $auth, $org_id, $match_copy, $chunk_size) = @_;
3615
3616     my $e = new_editor(authtoken=>$auth);
3617     $e->checkauth or return $e->die_event;
3618     my $cache = OpenSRF::Utils::Cache->new('global');
3619
3620     $org_id ||= $e->requestor->ws_ou;
3621     $e->allowed('UPDATE_HOLD', $org_id) or return $e->die_event;
3622
3623     my $copy_status = $U->ou_ancestor_setting_value($org_id, 'circ.holds.clear_shelf.copy_status');
3624
3625     my @hold_ids = $self->method_lookup(
3626         "open-ils.circ.captured_holds.id_list.expired_on_shelf.retrieve"
3627     )->run($auth, $org_id, $match_copy);
3628
3629     $e->xact_begin;
3630
3631     my @holds;
3632     my @canceled_holds; # newly canceled holds
3633     $chunk_size ||= 25; # chunked status updates
3634     $client->max_chunk_size($chunk_size) if (!$client->can('max_bundle_size') && $client->can('max_chunk_size'));
3635
3636     my $counter = 0;
3637     for my $hold_id (@hold_ids) {
3638
3639         $logger->info("Clear shelf processing hold $hold_id");
3640
3641         my $hold = $e->retrieve_action_hold_request([
3642             $hold_id, {
3643                 flesh => 1,
3644                 flesh_fields => {ahr => ['current_copy']}
3645             }
3646         ]);
3647
3648         if (!$hold->cancel_time) { # may be canceled but still on the holds shelf
3649             $hold->cancel_time('now');
3650             $hold->cancel_cause(2); # Hold Shelf expiration
3651             $e->update_action_hold_request($hold) or return $e->die_event;
3652             push(@canceled_holds, $hold_id);
3653         }
3654
3655         my $copy = $hold->current_copy;
3656
3657         if($copy_status or $copy_status == 0) {
3658             # if a clear-shelf copy status is defined, update the copy
3659             $copy->status($copy_status);
3660             $copy->edit_date('now');
3661             $copy->editor($e->requestor->id);
3662             $e->update_asset_copy($copy) or return $e->die_event;
3663         }
3664
3665         push(@holds, $hold);
3666         $client->respond({maximum => scalar(@holds), progress => $counter}) if ( (++$counter % $chunk_size) == 0);
3667     }
3668
3669     if ($e->commit) {
3670
3671         my %cache_data = (
3672             hold => [],
3673             transit => [],
3674             shelf => [],
3675             pl_changed => pickup_lib_changed_on_shelf_holds($e, $org_id, \@hold_ids)
3676         );
3677
3678         for my $hold (@holds) {
3679
3680             my $copy = $hold->current_copy;
3681             my ($alt_hold) = __PACKAGE__->find_nearest_permitted_hold($e, $copy, $e->requestor, 1);
3682
3683             if($alt_hold and !$match_copy) {
3684
3685                 push(@{$cache_data{hold}}, $hold->id); # copy is needed for a hold
3686
3687             } elsif($copy->circ_lib != $e->requestor->ws_ou) {
3688
3689                 push(@{$cache_data{transit}}, $hold->id); # copy needs to transit
3690
3691             } else {
3692
3693                 push(@{$cache_data{shelf}}, $hold->id); # copy needs to go back to the shelf
3694             }
3695         }
3696
3697         my $cache_key = md5_hex(time . $$ . rand());
3698         $logger->info("clear_shelf_cache: storing under $cache_key");
3699         $cache->put_cache($cache_key, \%cache_data, 7200); # TODO: 2 hours.  configurable?
3700
3701         # tell the client we're done
3702         $client->respond_complete({cache_key => $cache_key});
3703
3704         # ------------
3705         # fire off the hold cancelation trigger and wait for response so don't flood the service
3706
3707         # refetch the holds to pick up the caclulated cancel_time,
3708         # which may be needed by Action/Trigger
3709         $e->xact_begin;
3710         my $updated_holds = [];
3711         $updated_holds = $e->search_action_hold_request({id => \@canceled_holds}, {substream => 1}) if (@canceled_holds > 0);
3712         $e->rollback;
3713
3714         $U->create_events_for_hook(
3715             'hold_request.cancel.expire_holds_shelf',
3716             $_, $org_id, undef, undef, 1) for @$updated_holds;
3717
3718     } else {
3719         # tell the client we're done
3720         $client->respond_complete;
3721     }
3722 }
3723
3724 # returns IDs for holds that are on the holds shelf but 
3725 # have had their pickup_libs change while on the shelf.
3726 sub pickup_lib_changed_on_shelf_holds {
3727     my $e = shift;
3728     my $org_id = shift;
3729     my $ignore_holds = shift;
3730     $ignore_holds = [$ignore_holds] if !ref($ignore_holds);
3731
3732     my $query = {
3733         select => { alhr => ['id'] },
3734         from   => {
3735             alhr => {
3736                 acp => {
3737                     field => 'id',
3738                     fkey  => 'current_copy'
3739                 },
3740             }
3741         },
3742         where => {
3743             '+acp' => { status => OILS_COPY_STATUS_ON_HOLDS_SHELF },
3744             '+alhr' => {
3745                 capture_time     => { "!=" => undef },
3746                 fulfillment_time => undef,
3747                 current_shelf_lib => $org_id,
3748                 pickup_lib => {'!='  => {'+alhr' => 'current_shelf_lib'}}
3749             }
3750         }
3751     };
3752
3753     $query->{where}->{'+alhr'}->{id} =
3754         {'not in' => $ignore_holds} if @$ignore_holds;
3755
3756     my $hold_ids = $e->json_query($query);
3757     return [ map { $_->{id} } @$hold_ids ];
3758 }
3759
3760 __PACKAGE__->register_method(
3761     method    => 'usr_hold_summary',
3762     api_name  => 'open-ils.circ.holds.user_summary',
3763     signature => q/
3764         Returns a summary of holds statuses for a given user
3765     /
3766 );
3767
3768 sub usr_hold_summary {
3769     my($self, $conn, $auth, $user_id) = @_;
3770
3771     my $e = new_editor(authtoken=>$auth);
3772     $e->checkauth or return $e->event;
3773     $e->allowed('VIEW_HOLD') or return $e->event;
3774
3775     my $holds = $e->search_action_hold_request(
3776         {
3777             usr =>  $user_id ,
3778             fulfillment_time => undef,
3779             cancel_time      => undef,
3780         }
3781     );
3782
3783     my %summary = (1 => 0, 2 => 0, 3 => 0, 4 => 0);
3784     $summary{_hold_status($e, $_)} += 1 for @$holds;
3785     return \%summary;
3786 }
3787
3788
3789
3790 __PACKAGE__->register_method(
3791     method    => 'hold_has_copy_at',
3792     api_name  => 'open-ils.circ.hold.has_copy_at',
3793     signature => {
3794         desc   =>
3795                 'Returns the ID of the found copy and name of the shelving location if there is ' .
3796                 'an available copy at the specified org unit.  Returns empty hash otherwise.  '   .
3797                 'The anticipated use for this method is to determine whether an item is '         .
3798                 'available at the library where the user is placing the hold (or, alternatively, '.
3799                 'at the pickup library) to encourage bypassing the hold placement and just '      .
3800                 'checking out the item.' ,
3801         params => [
3802             { desc => 'Authentication Token', type => 'string' },
3803             { desc => 'Method Arguments.  Options include: hold_type, hold_target, org_unit.  '
3804                     . 'hold_type is the hold type code (T, V, C, M, ...).  '
3805                     . 'hold_target is the identifier of the hold target object.  '
3806                     . 'org_unit is org unit ID.',
3807               type => 'object'
3808             }
3809         ],
3810         return => {
3811             desc => q/Result hash like { "copy" : copy_id, "location" : location_name }, empty hash on misses, event on error./,
3812             type => 'object'
3813         }
3814     }
3815 );
3816
3817 sub hold_has_copy_at {
3818     my($self, $conn, $auth, $args) = @_;
3819
3820     my $e = new_editor(authtoken=>$auth);
3821     $e->checkauth or return $e->event;
3822
3823     my $hold_type   = $$args{hold_type};
3824     my $hold_target = $$args{hold_target};
3825     my $org_unit    = $$args{org_unit};
3826
3827     my $query = {
3828         select => {acp => ['id'], acpl => ['name']},
3829         from   => {
3830             acp => {
3831                 acpl => {
3832                     field => 'id',
3833                     filter => { holdable => 't', deleted => 'f' },
3834                     fkey => 'location'
3835                 },
3836                 ccs  => {field => 'id', filter => { holdable => 't'}, fkey => 'status'  }
3837             }
3838         },
3839         where => {'+acp' => { circulate => 't', deleted => 'f', holdable => 't', circ_lib => $org_unit, status => [0,7]}},
3840         limit => 1
3841     };
3842
3843     if($hold_type eq 'C' or $hold_type eq 'F' or $hold_type eq 'R') {
3844
3845         $query->{where}->{'+acp'}->{id} = $hold_target;
3846
3847     } elsif($hold_type eq 'V') {
3848
3849         $query->{where}->{'+acp'}->{call_number} = $hold_target;
3850
3851     } elsif($hold_type eq 'P') {
3852
3853         $query->{from}->{acp}->{acpm} = {
3854             field  => 'target_copy',
3855             fkey   => 'id',
3856             filter => {part => $hold_target},
3857         };
3858
3859     } elsif($hold_type eq 'I') {
3860
3861         $query->{from}->{acp}->{sitem} = {
3862             field  => 'unit',
3863             fkey   => 'id',
3864             filter => {issuance => $hold_target},
3865         };
3866
3867     } elsif($hold_type eq 'T') {
3868
3869         $query->{from}->{acp}->{acn} = {
3870             field  => 'id',
3871             fkey   => 'call_number',
3872             'join' => {
3873                 bre => {
3874                     field  => 'id',
3875                     filter => {id => $hold_target},
3876                     fkey   => 'record'
3877                 }
3878             }
3879         };
3880
3881     } else {
3882
3883         $query->{from}->{acp}->{acn} = {
3884             field => 'id',
3885             fkey  => 'call_number',
3886             join  => {
3887                 bre => {
3888                     field => 'id',
3889                     fkey  => 'record',
3890                     join  => {
3891                         mmrsm => {
3892                             field  => 'source',
3893                             fkey   => 'id',
3894                             filter => {metarecord => $hold_target},
3895                         }
3896                     }
3897                 }
3898             }
3899         };
3900     }
3901
3902     my $res = $e->json_query($query)->[0] or return {};
3903     return {copy => $res->{id}, location => $res->{name}} if $res;
3904 }
3905
3906
3907 # returns true if the user already has an item checked out
3908 # that could be used to fulfill the requested hold.
3909 sub hold_item_is_checked_out {
3910     my($e, $user_id, $hold_type, $hold_target) = @_;
3911
3912     my $query = {
3913         select => {acp => ['id']},
3914         from   => {acp => {}},
3915         where  => {
3916             '+acp' => {
3917                 id => {
3918                     in => { # copies for circs the user has checked out
3919                         select => {circ => ['target_copy']},
3920                         from   => 'circ',
3921                         where  => {
3922                             usr => $user_id,
3923                             checkin_time => undef,
3924                             '-or' => [
3925                                 {stop_fines => ["MAXFINES","LONGOVERDUE"]},
3926                                 {stop_fines => undef}
3927                             ],
3928                         }
3929                     }
3930                 }
3931             }
3932         },
3933         limit => 1
3934     };
3935
3936     if($hold_type eq 'C' || $hold_type eq 'R' || $hold_type eq 'F') {
3937
3938         $query->{where}->{'+acp'}->{id}->{in}->{where}->{'target_copy'} = $hold_target;
3939
3940     } elsif($hold_type eq 'V') {
3941
3942         $query->{where}->{'+acp'}->{call_number} = $hold_target;
3943
3944      } elsif($hold_type eq 'P') {
3945
3946         $query->{from}->{acp}->{acpm} = {
3947             field  => 'target_copy',
3948             fkey   => 'id',
3949             filter => {part => $hold_target},
3950         };
3951
3952      } elsif($hold_type eq 'I') {
3953
3954         $query->{from}->{acp}->{sitem} = {
3955             field  => 'unit',
3956             fkey   => 'id',
3957             filter => {issuance => $hold_target},
3958         };
3959
3960     } elsif($hold_type eq 'T') {
3961
3962         $query->{from}->{acp}->{acn} = {
3963             field  => 'id',
3964             fkey   => 'call_number',
3965             'join' => {
3966                 bre => {
3967                     field  => 'id',
3968                     filter => {id => $hold_target},
3969                     fkey   => 'record'
3970                 }
3971             }
3972         };
3973
3974     } else {
3975
3976         $query->{from}->{acp}->{acn} = {
3977             field => 'id',
3978             fkey => 'call_number',
3979             join => {
3980                 bre => {
3981                     field => 'id',
3982                     fkey => 'record',
3983                     join => {
3984                         mmrsm => {
3985                             field => 'source',
3986                             fkey => 'id',
3987                             filter => {metarecord => $hold_target},
3988                         }
3989                     }
3990                 }
3991             }
3992         };
3993     }
3994
3995     return $e->json_query($query)->[0];
3996 }
3997
3998 __PACKAGE__->register_method(
3999     method    => 'change_hold_title',
4000     api_name  => 'open-ils.circ.hold.change_title',
4001     signature => {
4002         desc => q/
4003             Updates all title level holds targeting the specified bibs to point a new bib./,
4004         params => [
4005             { desc => 'Authentication Token', type => 'string' },
4006             { desc => 'New Target Bib Id',    type => 'number' },
4007             { desc => 'Old Target Bib Ids',   type => 'array'  },
4008         ],
4009         return => { desc => '1 on success' }
4010     }
4011 );
4012
4013 __PACKAGE__->register_method(
4014     method    => 'change_hold_title_for_specific_holds',
4015     api_name  => 'open-ils.circ.hold.change_title.specific_holds',
4016     signature => {
4017         desc => q/
4018             Updates specified holds to target new bib./,
4019         params => [
4020             { desc => 'Authentication Token', type => 'string' },
4021             { desc => 'New Target Bib Id',    type => 'number' },
4022             { desc => 'Holds Ids for holds to update',   type => 'array'  },
4023         ],
4024         return => { desc => '1 on success' }
4025     }
4026 );
4027
4028
4029 sub change_hold_title {
4030     my( $self, $client, $auth, $new_bib_id, $bib_ids ) = @_;
4031
4032     my $e = new_editor(authtoken=>$auth, xact=>1);
4033     return $e->die_event unless $e->checkauth;
4034
4035     my $holds = $e->search_action_hold_request(
4036         [
4037             {
4038                 cancel_time      => undef,
4039                 fulfillment_time => undef,
4040                 hold_type        => 'T',
4041                 target           => $bib_ids
4042             },
4043             {
4044                 flesh        => 1,
4045                 flesh_fields => { ahr => ['usr'] }
4046             }
4047         ],
4048         { substream => 1 }
4049     );
4050
4051     for my $hold (@$holds) {
4052         $e->allowed('UPDATE_HOLD', $hold->usr->home_ou) or return $e->die_event;
4053         $logger->info("Changing hold " . $hold->id . " target from " . $hold->target . " to $new_bib_id in title hold target change");
4054         $hold->target( $new_bib_id );
4055         $e->update_action_hold_request($hold) or return $e->die_event;
4056     }
4057
4058     $e->commit;
4059
4060     _reset_hold($self, $e->requestor, $_) for @$holds;
4061
4062     return 1;
4063 }
4064
4065 sub change_hold_title_for_specific_holds {
4066     my( $self, $client, $auth, $new_bib_id, $hold_ids ) = @_;
4067
4068     my $e = new_editor(authtoken=>$auth, xact=>1);
4069     return $e->die_event unless $e->checkauth;
4070
4071     my $holds = $e->search_action_hold_request(
4072         [
4073             {
4074                 cancel_time      => undef,
4075                 fulfillment_time => undef,
4076                 hold_type        => 'T',
4077                 id               => $hold_ids
4078             },
4079             {
4080                 flesh        => 1,
4081                 flesh_fields => { ahr => ['usr'] }
4082             }
4083         ],
4084         { substream => 1 }
4085     );
4086
4087     for my $hold (@$holds) {
4088         $e->allowed('UPDATE_HOLD', $hold->usr->home_ou) or return $e->die_event;
4089         $logger->info("Changing hold " . $hold->id . " target from " . $hold->target . " to $new_bib_id in title hold target change");
4090         $hold->target( $new_bib_id );
4091         $e->update_action_hold_request($hold) or return $e->die_event;
4092     }
4093
4094     $e->commit;
4095
4096     _reset_hold($self, $e->requestor, $_) for @$holds;
4097
4098     return 1;
4099 }
4100
4101 __PACKAGE__->register_method(
4102     method    => 'rec_hold_count',
4103     api_name  => 'open-ils.circ.bre.holds.count',
4104     signature => {
4105         desc => q/Returns the total number of holds that target the
4106             selected bib record or its associated copies and call_numbers/,
4107         params => [
4108             { desc => 'Bib ID', type => 'number' },
4109             { desc => q/Optional arguments.  Supported arguments include:
4110                 "pickup_lib_descendant" -> limit holds to those whose pickup
4111                 library is equal to or is a child of the provided org unit/,
4112                 type => 'object'
4113             }
4114         ],
4115         return => {desc => 'Hold count', type => 'number'}
4116     }
4117 );
4118
4119 __PACKAGE__->register_method(
4120     method    => 'rec_hold_count',
4121     api_name  => 'open-ils.circ.mmr.holds.count',
4122     signature => {
4123         desc => q/Returns the total number of holds that target the
4124             selected metarecord or its associated copies, call_numbers, and bib records/,
4125         params => [
4126             { desc => 'Metarecord ID', type => 'number' },
4127         ],
4128         return => {desc => 'Hold count', type => 'number'}
4129     }
4130 );
4131
4132 # XXX Need to add type I holds to these counts
4133 sub rec_hold_count {
4134     my($self, $conn, $target_id, $args) = @_;
4135     $args ||= {};
4136
4137     my $mmr_join = {
4138         mmrsm => {
4139             field => 'source',
4140             fkey => 'id',
4141             filter => {metarecord => $target_id}
4142         }
4143     };
4144
4145     my $bre_join = {
4146         bre => {
4147             field => 'id',
4148             filter => { id => $target_id },
4149             fkey => 'record'
4150         }
4151     };
4152
4153     if($self->api_name =~ /mmr/) {
4154         delete $bre_join->{bre}->{filter};
4155         $bre_join->{bre}->{join} = $mmr_join;
4156     }
4157
4158     my $cn_join = {
4159         acn => {
4160             field => 'id',
4161             fkey => 'call_number',
4162             join => $bre_join
4163         }
4164     };
4165
4166     my $query = {
4167         select => {ahr => [{column => 'id', transform => 'count', alias => 'count'}]},
4168         from => 'ahr',
4169         where => {
4170             '+ahr' => {
4171                 cancel_time => undef,
4172                 fulfillment_time => undef,
4173                 '-or' => [
4174                     {
4175                         '-and' => {
4176                             hold_type => [qw/C F R/],
4177                             target => {
4178                                 in => {
4179                                     select => {acp => ['id']},
4180                                     from => { acp => $cn_join }
4181                                 }
4182                             }
4183                         }
4184                     },
4185                     {
4186                         '-and' => {
4187                             hold_type => 'V',
4188                             target => {
4189                                 in => {
4190                                     select => {acn => ['id']},
4191                                     from => {acn => $bre_join}
4192                                 }
4193                             }
4194                         }
4195                     },
4196                     {
4197                         '-and' => {
4198                             hold_type => 'P',
4199                             target => {
4200                                 in => {
4201                                     select => {bmp => ['id']},
4202                                     from => {bmp => $bre_join}
4203                                 }
4204                             }
4205                         }
4206                     },
4207                     {
4208                         '-and' => {
4209                             hold_type => 'T',
4210                             target => $target_id
4211                         }
4212                     }
4213                 ]
4214             }
4215         }
4216     };
4217
4218     if($self->api_name =~ /mmr/) {
4219         $query->{where}->{'+ahr'}->{'-or'}->[3] = {
4220             '-and' => {
4221                 hold_type => 'T',
4222                 target => {
4223                     in => {
4224                         select => {bre => ['id']},
4225                         from => {bre => $mmr_join}
4226                     }
4227                 }
4228             }
4229         };
4230
4231         $query->{where}->{'+ahr'}->{'-or'}->[4] = {
4232             '-and' => {
4233                 hold_type => 'M',
4234                 target => $target_id
4235             }
4236         };
4237     }
4238
4239
4240     if (my $pld = $args->{pickup_lib_descendant}) {
4241
4242         my $top_ou = new_editor()->search_actor_org_unit(
4243             {parent_ou => undef}
4244         )->[0]; # XXX Assumes single root node. Not alone in this...
4245
4246         $query->{where}->{'+ahr'}->{pickup_lib} = {
4247             in => {
4248                 select  => {aou => [{ 
4249                     column => 'id', 
4250                     transform => 'actor.org_unit_descendants', 
4251                     result_field => 'id' 
4252                 }]},
4253                 from    => 'aou',
4254                 where   => {id => $pld}
4255             }
4256         } if ($pld != $top_ou->id);
4257     }
4258
4259
4260     return new_editor()->json_query($query)->[0]->{count};
4261 }
4262
4263 # A helper function to calculate a hold's expiration time at a given
4264 # org_unit. Takes the org_unit as an argument and returns either the
4265 # hold expire time as an ISO8601 string or undef if there is no hold
4266 # expiration interval set for the subject ou.
4267 sub calculate_expire_time
4268 {
4269     my $ou = shift;
4270     my $interval = $U->ou_ancestor_setting_value($ou, OILS_SETTING_HOLD_EXPIRE);
4271     if($interval) {
4272         my $date = DateTime->now->add(seconds => OpenSRF::Utils::interval_to_seconds($interval));
4273         return $U->epoch2ISO8601($date->epoch);
4274     }
4275     return undef;
4276 }
4277
4278
4279 __PACKAGE__->register_method(
4280     method    => 'mr_hold_filter_attrs',
4281     api_name  => 'open-ils.circ.mmr.holds.filters',
4282     authoritative => 1,
4283     stream => 1,
4284     signature => {
4285         desc => q/
4286             Returns the set of available formats and languages for the
4287             constituent records of the provided metarcord.
4288             If an array of hold IDs is also provided, information about
4289             each is returned as well.  This information includes:
4290             1. a slightly easier to read version of holdable_formats
4291             2. attributes describing the set of format icons included
4292                in the set of desired, constituent records.
4293         /,
4294         params => [
4295             {desc => 'Metarecord ID', type => 'number'},
4296             {desc => 'Context Org ID', type => 'number'},
4297             {desc => 'Hold ID List', type => 'array'},
4298         ],
4299         return => {
4300             desc => q/
4301                 Stream of objects.  The first will have a 'metarecord' key
4302                 containing non-hold-specific metarecord information, subsequent
4303                 responses will contain a 'hold' key containing hold-specific
4304                 information
4305             /, 
4306             type => 'object'
4307         }
4308     }
4309 );
4310
4311 sub mr_hold_filter_attrs { 
4312     my ($self, $client, $mr_id, $org_id, $hold_ids) = @_;
4313     my $e = new_editor();
4314
4315     # by default, return MR / hold attributes for all constituent
4316     # records with holdable copies.  If there is a hard boundary,
4317     # though, limit to records with copies within the boundary,
4318     # since anything outside the boundary can never be held.
4319     my $org_depth = 0;
4320     if ($org_id) {
4321         $org_depth = $U->ou_ancestor_setting_value(
4322             $org_id, OILS_SETTING_HOLD_HARD_BOUNDARY) || 0;
4323     }
4324
4325     # get all org-scoped records w/ holdable copies for this metarecord
4326     my ($bre_ids) = $self->method_lookup(
4327         'open-ils.circ.holds.metarecord.filtered_records')->run(
4328             $mr_id, undef, $org_id, $org_depth);
4329
4330     my $item_lang_attr = 'item_lang'; # configurable?
4331     my $format_attr = $e->retrieve_config_global_flag(
4332         'opac.metarecord.holds.format_attr')->value;
4333
4334     # helper sub for fetching ccvms for a batch of record IDs
4335     sub get_batch_ccvms {
4336         my ($e, $attr, $bre_ids) = @_;
4337         return [] unless $bre_ids and @$bre_ids;
4338         my $vals = $e->search_metabib_record_attr_flat({
4339             attr => $attr,
4340             id => $bre_ids
4341         });
4342         return [] unless @$vals;
4343         return $e->search_config_coded_value_map({
4344             ctype => $attr,
4345             code => [map {$_->value} @$vals]
4346         });
4347     }
4348
4349     my $langs = get_batch_ccvms($e, $item_lang_attr, $bre_ids);
4350     my $formats = get_batch_ccvms($e, $format_attr, $bre_ids);
4351
4352     $client->respond({
4353         metarecord => {
4354             id => $mr_id,
4355             formats => $formats,
4356             langs => $langs
4357         }
4358     });
4359
4360     return unless $hold_ids;
4361     my $icon_attr = $e->retrieve_config_global_flag('opac.icon_attr');
4362     $icon_attr = $icon_attr ? $icon_attr->value : '';
4363
4364     for my $hold_id (@$hold_ids) {
4365         my $hold = $e->retrieve_action_hold_request($hold_id) 
4366             or return $e->event;
4367
4368         next unless $hold->hold_type eq 'M';
4369
4370         my $resp = {
4371             hold => {
4372                 id => $hold_id,
4373                 formats => [],
4374                 langs => []
4375             }
4376         };
4377
4378         # collect the ccvm's for the selected formats / language
4379         # (i.e. the holdable formats) on the MR.
4380         # this assumes a two-key structure for format / language,
4381         # though no assumption is made about the keys themselves.
4382         my $hformats = OpenSRF::Utils::JSON->JSON2perl($hold->holdable_formats);
4383         my $lang_vals = [];
4384         my $format_vals = [];
4385         for my $val (values %$hformats) {
4386             # val is either a single ccvm or an array of them
4387             $val = [$val] unless ref $val eq 'ARRAY';
4388             for my $node (@$val) {
4389                 push (@$lang_vals, $node->{_val})   
4390                     if $node->{_attr} eq $item_lang_attr; 
4391                 push (@$format_vals, $node->{_val})   
4392                     if $node->{_attr} eq $format_attr;
4393             }
4394         }
4395
4396         # fetch the ccvm's for consistency with the {metarecord} blob
4397         $resp->{hold}{formats} = $e->search_config_coded_value_map({
4398             ctype => $format_attr, code => $format_vals});
4399         $resp->{hold}{langs} = $e->search_config_coded_value_map({
4400             ctype => $item_lang_attr, code => $lang_vals});
4401
4402         # find all of the bib records within this metarcord whose 
4403         # format / language match the holdable formats on the hold
4404         my ($bre_ids) = $self->method_lookup(
4405             'open-ils.circ.holds.metarecord.filtered_records')->run(
4406                 $hold->target, $hold->holdable_formats, 
4407                 $hold->selection_ou, $hold->selection_depth);
4408
4409         # now find all of the 'icon' attributes for the records
4410         $resp->{hold}{icons} = get_batch_ccvms($e, $icon_attr, $bre_ids);
4411         $client->respond($resp);
4412     }
4413
4414     return;
4415 }
4416
4417 __PACKAGE__->register_method(
4418     method        => "copy_has_holds_count",
4419     api_name      => "open-ils.circ.copy.has_holds_count",
4420     authoritative => 1,
4421     signature     => {
4422         desc => q/
4423             Returns the number of holds a paticular copy has
4424         /,
4425         params => [
4426             { desc => 'Authentication Token', type => 'string'},
4427             { desc => 'Copy ID', type => 'number'}
4428         ],
4429         return => {
4430             desc => q/
4431                 Simple count value
4432             /,
4433             type => 'number'
4434         }
4435     }
4436 );
4437
4438 sub copy_has_holds_count {
4439     my( $self, $conn, $auth, $copyid ) = @_;
4440     my $e = new_editor(authtoken=>$auth);
4441     return $e->event unless $e->checkauth;
4442
4443     if( $copyid && $copyid > 0 ) {
4444         my $meth = 'retrieve_action_has_holds_count';
4445         my $data = $e->$meth($copyid);
4446         if($data){
4447                 return $data->count();
4448         }
4449     }
4450     return 0;
4451 }
4452
4453 1;