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