]> git.evergreen-ils.org Git - working/Evergreen.git/blob - Open-ILS/src/perlmods/lib/OpenILS/Application/Circ/Holds.pm
144098ede7bfa0b8bde05e26e82a033ec0042b85
[working/Evergreen.git] / Open-ILS / src / perlmods / lib / OpenILS / Application / Circ / Holds.pm
1 # ---------------------------------------------------------------
2 # Copyright (C) 2005  Georgia Public Library Service
3 # Bill Erickson <highfalutin@gmail.com>
4
5 # This program is free software; you can redistribute it and/or
6 # modify it under the terms of the GNU General Public License
7 # as published by the Free Software Foundation; either version 2
8 # of the License, or (at your option) any later version.
9
10 # This program is distributed in the hope that it will be useful,
11 # but WITHOUT ANY WARRANTY; without even the implied warranty of
12 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
13 # GNU General Public License for more details.
14 # ---------------------------------------------------------------
15
16
17 package OpenILS::Application::Circ::Holds;
18 use base qw/OpenILS::Application/;
19 use strict; use warnings;
20 use OpenILS::Application::AppUtils;
21 use DateTime;
22 use Data::Dumper;
23 use OpenSRF::EX qw(:try);
24 use OpenILS::Perm;
25 use OpenILS::Event;
26 use OpenSRF::Utils;
27 use OpenSRF::Utils::Logger qw(:logger);
28 use OpenILS::Utils::CStoreEditor q/:funcs/;
29 use OpenILS::Utils::PermitHold;
30 use OpenSRF::Utils::SettingsClient;
31 use OpenILS::Const qw/:const/;
32 use OpenILS::Application::Circ::Transit;
33 use OpenILS::Application::Actor::Friends;
34 use DateTime;
35 use DateTime::Format::ISO8601;
36 use OpenSRF::Utils qw/:datetime/;
37 use Digest::MD5 qw(md5_hex);
38 use OpenSRF::Utils::Cache;
39 use OpenSRF::Utils::JSON;
40 my $apputils = "OpenILS::Application::AppUtils";
41 my $U = $apputils;
42
43 __PACKAGE__->register_method(
44     method    => "test_and_create_hold_batch",
45     api_name  => "open-ils.circ.holds.test_and_create.batch",
46     stream => 1,
47     signature => {
48         desc => q/This is for batch creating a set of holds where every field is identical except for the targets./,
49         params => [
50             { desc => 'Authentication token', type => 'string' },
51             { desc => 'Hash of named parameters.  Same as for open-ils.circ.title_hold.is_possible, though the pertinent target field is automatically populated based on the hold_type and the specified list of targets.', type => 'object'},
52             { desc => 'Array of target ids', type => 'array' }
53         ],
54         return => {
55             desc => 'Array of hold ID on success, -1 on missing arg, event (or ref to array of events) on error(s)',
56         },
57     }
58 );
59
60 __PACKAGE__->register_method(
61     method    => "test_and_create_hold_batch",
62     api_name  => "open-ils.circ.holds.test_and_create.batch.override",
63     stream => 1,
64     signature => {
65         desc  => '@see open-ils.circ.holds.test_and_create.batch',
66     }
67 );
68
69
70 sub test_and_create_hold_batch {
71     my( $self, $conn, $auth, $params, $target_list, $oargs ) = @_;
72
73     my $override = 0;
74     if ($self->api_name =~ /override/) {
75         $override = 1;
76         $oargs = { all => 1 } unless defined $oargs;
77         $$params{oargs} = $oargs; # for is_possible checking.
78     }
79
80     my $e = new_editor(authtoken=>$auth);
81     return $e->die_event unless $e->checkauth;
82     $$params{'requestor'} = $e->requestor->id;
83
84     my $target_field;
85     if ($$params{'hold_type'} eq 'T') { $target_field = 'titleid'; }
86     elsif ($$params{'hold_type'} eq 'C') { $target_field = 'copy_id'; }
87     elsif ($$params{'hold_type'} eq 'R') { $target_field = 'copy_id'; }
88     elsif ($$params{'hold_type'} eq 'F') { $target_field = 'copy_id'; }
89     elsif ($$params{'hold_type'} eq 'I') { $target_field = 'issuanceid'; }
90     elsif ($$params{'hold_type'} eq 'V') { $target_field = 'volume_id'; }
91     elsif ($$params{'hold_type'} eq 'M') { $target_field = 'mrid'; }
92     elsif ($$params{'hold_type'} eq 'P') { $target_field = 'partid'; }
93     else { return undef; }
94
95     my $formats_map = delete $$params{holdable_formats_map};
96
97     foreach (@$target_list) {
98         $$params{$target_field} = $_;
99
100         # copy the requested formats from the target->formats map
101         # into the top-level formats attr for each hold
102         $$params{holdable_formats} = $formats_map->{$_};
103
104         my $res;
105         ($res) = $self->method_lookup(
106             'open-ils.circ.title_hold.is_possible')->run($auth, $params, $override ? $oargs : {});
107         if ($res->{'success'} == 1) {
108
109             $params->{'depth'} = $res->{'depth'} if $res->{'depth'};
110
111             # Remove oargs from params so holds can be created.
112             if ($$params{oargs}) {
113                 delete $$params{oargs};
114             }
115
116             my $ahr = construct_hold_request_object($params);
117             my ($res2) = $self->method_lookup(
118                 $override
119                 ? 'open-ils.circ.holds.create.override'
120                 : 'open-ils.circ.holds.create'
121             )->run($auth, $ahr, $oargs);
122             $res2 = {
123                 'target' => $$params{$target_field},
124                 'result' => $res2
125             };
126             $conn->respond($res2);
127         } else {
128             $res = {
129                 'target' => $$params{$target_field},
130                 'result' => $res
131             };
132             $conn->respond($res);
133         }
134     }
135     return undef;
136 }
137
138 sub construct_hold_request_object {
139     my ($params) = @_;
140
141     my $ahr = Fieldmapper::action::hold_request->new;
142     $ahr->isnew('1');
143
144     foreach my $field (keys %{ $params }) {
145         if ($field eq 'depth') { $ahr->selection_depth($$params{$field}); }
146         elsif ($field eq 'patronid') {
147             $ahr->usr($$params{$field}); }
148         elsif ($field eq 'titleid') { $ahr->target($$params{$field}); }
149         elsif ($field eq 'copy_id') { $ahr->target($$params{$field}); }
150         elsif ($field eq 'issuanceid') { $ahr->target($$params{$field}); }
151         elsif ($field eq 'volume_id') { $ahr->target($$params{$field}); }
152         elsif ($field eq 'mrid') { $ahr->target($$params{$field}); }
153         elsif ($field eq 'partid') { $ahr->target($$params{$field}); }
154         else {
155             $ahr->$field($$params{$field});
156         }
157     }
158     return $ahr;
159 }
160
161 __PACKAGE__->register_method(
162     method    => "create_hold_batch",
163     api_name  => "open-ils.circ.holds.create.batch",
164     stream => 1,
165     signature => {
166         desc => q/@see open-ils.circ.holds.create.batch/,
167         params => [
168             { desc => 'Authentication token', type => 'string' },
169             { desc => 'Array of hold objects', type => 'array' }
170         ],
171         return => {
172             desc => 'Array of hold ID on success, -1 on missing arg, event (or ref to array of events) on error(s)',
173         },
174     }
175 );
176
177 __PACKAGE__->register_method(
178     method    => "create_hold_batch",
179     api_name  => "open-ils.circ.holds.create.override.batch",
180     stream => 1,
181     signature => {
182         desc  => '@see open-ils.circ.holds.create.batch',
183     }
184 );
185
186
187 sub create_hold_batch {
188     my( $self, $conn, $auth, $hold_list, $oargs ) = @_;
189     (my $method = $self->api_name) =~ s/\.batch//og;
190     foreach (@$hold_list) {
191         my ($res) = $self->method_lookup($method)->run($auth, $_, $oargs);
192         $conn->respond($res);
193     }
194     return undef;
195 }
196
197
198 __PACKAGE__->register_method(
199     method    => "create_hold",
200     api_name  => "open-ils.circ.holds.create",
201     signature => {
202         desc => "Create a new hold for an item.  From a permissions perspective, " .
203                 "the login session is used as the 'requestor' of the hold.  "      .
204                 "The hold recipient is determined by the 'usr' setting within the hold object. " .
205                 'First we verify the requestor has holds request permissions.  '         .
206                 'Then we verify that the recipient is allowed to make the given hold.  ' .
207                 'If not, we see if the requestor has "override" capabilities.  If not, ' .
208                 'a permission exception is returned.  If permissions allow, we cycle '   .
209                 'through the set of holds objects and create.  '                         .
210                 'If the recipient does not have permission to place multiple holds '     .
211                 'on a single title and said operation is attempted, a permission '       .
212                 'exception is returned',
213         params => [
214             { desc => 'Authentication token',               type => 'string' },
215             { desc => 'Hold object for hold to be created',
216                 type => 'object', class => 'ahr' }
217         ],
218         return => {
219             desc => 'New ahr ID on success, -1 on missing arg, event (or ref to array of events) on error(s)',
220         },
221     }
222 );
223
224 __PACKAGE__->register_method(
225     method    => "create_hold",
226     api_name  => "open-ils.circ.holds.create.override",
227     notes     => '@see open-ils.circ.holds.create',
228     signature => {
229         desc  => "If the recipient is not allowed to receive the requested hold, " .
230                  "call this method to attempt the override",
231         params => [
232             { desc => 'Authentication token',               type => 'string' },
233             {
234                 desc => 'Hold object for hold to be created',
235                 type => 'object', class => 'ahr'
236             }
237         ],
238         return => {
239             desc => 'New hold (ahr) ID on success, -1 on missing arg, event (or ref to array of events) on error(s)',
240         },
241     }
242 );
243
244 sub create_hold {
245     my( $self, $conn, $auth, $hold, $oargs ) = @_;
246     return -1 unless $hold;
247     my $e = new_editor(authtoken=>$auth, xact=>1);
248     return $e->die_event unless $e->checkauth;
249
250     my $override = 0;
251     if ($self->api_name =~ /override/) {
252         $override = 1;
253         $oargs = { all => 1 } unless defined $oargs;
254     }
255
256     my @events;
257
258     my $requestor = $e->requestor;
259     my $recipient = $requestor;
260
261     if( $requestor->id ne $hold->usr ) {
262         # Make sure the requestor is allowed to place holds for
263         # the recipient if they are not the same people
264         $recipient = $e->retrieve_actor_user($hold->usr)  or return $e->die_event;
265         $e->allowed('REQUEST_HOLDS', $recipient->home_ou) or return $e->die_event;
266     }
267
268     # If the related org setting tells us to, block if patron privs have expired
269     my $expire_setting = $U->ou_ancestor_setting_value($recipient->home_ou, OILS_SETTING_BLOCK_HOLD_FOR_EXPIRED_PATRON);
270     if ($expire_setting) {
271         my $expire = DateTime::Format::ISO8601->new->parse_datetime(
272             cleanse_ISO8601($recipient->expire_date));
273
274         push( @events, OpenILS::Event->new(
275             'PATRON_ACCOUNT_EXPIRED',
276             "payload" => {"fail_part" => "actor.usr.privs_expired"}
277             )) if( CORE::time > $expire->epoch ) ;
278     }
279
280     # Now make sure the recipient is allowed to receive the specified hold
281     my $porg = $recipient->home_ou;
282     my $rid  = $e->requestor->id;
283     my $t    = $hold->hold_type;
284
285     # See if a duplicate hold already exists
286     my $sargs = {
287         usr              => $recipient->id,
288         hold_type        => $t,
289         fulfillment_time => undef,
290         target           => $hold->target,
291         cancel_time      => undef,
292     };
293
294     $sargs->{holdable_formats} = $hold->holdable_formats if $t eq 'M';
295
296     my $existing = $e->search_action_hold_request($sargs);
297     push( @events, OpenILS::Event->new('HOLD_EXISTS')) if @$existing;
298
299     my $checked_out = hold_item_is_checked_out($e, $recipient->id, $hold->hold_type, $hold->target);
300     push( @events, OpenILS::Event->new('HOLD_ITEM_CHECKED_OUT')) if $checked_out;
301
302     if ( $t eq OILS_HOLD_TYPE_METARECORD ) {
303         return $e->die_event unless $e->allowed('MR_HOLDS',     $porg);
304     } elsif ( $t eq OILS_HOLD_TYPE_TITLE ) {
305         return $e->die_event unless $e->allowed('TITLE_HOLDS',  $porg);
306     } elsif ( $t eq OILS_HOLD_TYPE_VOLUME ) {
307         return $e->die_event unless $e->allowed('VOLUME_HOLDS', $porg);
308     } elsif ( $t eq OILS_HOLD_TYPE_MONOPART ) {
309         return $e->die_event unless $e->allowed('TITLE_HOLDS', $porg);
310     } elsif ( $t eq OILS_HOLD_TYPE_ISSUANCE ) {
311         return $e->die_event unless $e->allowed('ISSUANCE_HOLDS', $porg);
312     } elsif ( $t eq OILS_HOLD_TYPE_COPY ) {
313         return $e->die_event unless $e->allowed('COPY_HOLDS',   $porg);
314     } elsif ( $t eq OILS_HOLD_TYPE_FORCE || $t eq OILS_HOLD_TYPE_RECALL ) {
315         my $copy = $e->retrieve_asset_copy($hold->target)
316             or return $e->die_event;
317         if ( $t eq OILS_HOLD_TYPE_FORCE ) {
318             return $e->die_event unless $e->allowed('COPY_HOLDS_FORCE',   $copy->circ_lib);
319         } elsif ( $t eq OILS_HOLD_TYPE_RECALL ) {
320             return $e->die_event unless $e->allowed('COPY_HOLDS_RECALL',   $copy->circ_lib);
321         }
322     }
323
324     if( @events ) {
325         if (!$override) {
326             $e->rollback;
327             return \@events;
328         }
329         for my $evt (@events) {
330             next unless $evt;
331             my $name = $evt->{textcode};
332             if($oargs->{all} || grep { $_ eq $name } @{$oargs->{events}}) {
333                 return $e->die_event unless $e->allowed("$name.override", $porg);
334             } else {
335                 $e->rollback;
336                 return \@events;
337             }
338         }
339     }
340
341         # Check for hold expiration in the past, and set it to empty string.
342         $hold->expire_time(undef) if ($hold->expire_time && $U->datecmp($hold->expire_time) == -1);
343
344     # set the configured expire time
345     unless($hold->expire_time) {
346         $hold->expire_time(calculate_expire_time($recipient->home_ou));
347     }
348
349
350     # if behind-the-desk pickup is supported at the hold pickup lib,
351     # set the value to the patron default, unless a value has already
352     # been applied.  If it's not supported, force the value to false.
353
354     my $bdous = $U->ou_ancestor_setting_value(
355         $hold->pickup_lib, 
356         'circ.holds.behind_desk_pickup_supported', $e);
357
358     if ($bdous) {
359         if (!defined $hold->behind_desk) {
360
361             my $set = $e->search_actor_user_setting({
362                 usr => $hold->usr, 
363                 name => 'circ.holds_behind_desk'
364             })->[0];
365         
366             $hold->behind_desk('t') if $set and 
367                 OpenSRF::Utils::JSON->JSON2perl($set->value);
368         }
369     } else {
370         # behind the desk not supported, force it to false
371         $hold->behind_desk('f');
372     }
373
374     $hold->requestor($e->requestor->id);
375     $hold->request_lib($e->requestor->ws_ou);
376     $hold->selection_ou($hold->pickup_lib) unless $hold->selection_ou;
377     $hold = $e->create_action_hold_request($hold) or return $e->die_event;
378
379     $e->commit;
380
381     $conn->respond_complete($hold->id);
382
383     $U->storagereq(
384         'open-ils.storage.action.hold_request.copy_targeter',
385         undef, $hold->id ) unless $U->is_true($hold->frozen);
386
387     return undef;
388 }
389
390 # makes sure that a user has permission to place the type of requested hold
391 # returns the Perm exception if not allowed, returns undef if all is well
392 sub _check_holds_perm {
393     my($type, $user_id, $org_id) = @_;
394
395     my $evt;
396     if ($type eq "M") {
397         $evt = $apputils->check_perms($user_id, $org_id, "MR_HOLDS"    );
398     } elsif ($type eq "T") {
399         $evt = $apputils->check_perms($user_id, $org_id, "TITLE_HOLDS" );
400     } elsif($type eq "V") {
401         $evt = $apputils->check_perms($user_id, $org_id, "VOLUME_HOLDS");
402     } elsif($type eq "C") {
403         $evt = $apputils->check_perms($user_id, $org_id, "COPY_HOLDS"  );
404     }
405
406     return $evt if $evt;
407     return undef;
408 }
409
410 # tests if the given user is allowed to place holds on another's behalf
411 sub _check_request_holds_perm {
412     my $user_id = shift;
413     my $org_id  = shift;
414     if (my $evt = $apputils->check_perms(
415         $user_id, $org_id, "REQUEST_HOLDS")) {
416         return $evt;
417     }
418 }
419
420 my $ses_is_req_note = 'The login session is the requestor.  If the requestor is different from the user, ' .
421                       'then the requestor must have VIEW_HOLD permissions';
422
423 __PACKAGE__->register_method(
424     method    => "retrieve_holds_by_id",
425     api_name  => "open-ils.circ.holds.retrieve_by_id",
426     signature => {
427         desc   => "Retrieve the hold, with hold transits attached, for the specified ID.  $ses_is_req_note",
428         params => [
429             { desc => 'Authentication token', type => 'string' },
430             { desc => 'Hold ID',              type => 'number' }
431         ],
432         return => {
433             desc => 'Hold object with transits attached, event on error',
434         }
435     }
436 );
437
438
439 sub retrieve_holds_by_id {
440     my($self, $client, $auth, $hold_id) = @_;
441     my $e = new_editor(authtoken=>$auth);
442     $e->checkauth or return $e->event;
443     $e->allowed('VIEW_HOLD') or return $e->event;
444
445     my $holds = $e->search_action_hold_request(
446         [
447             { id =>  $hold_id , fulfillment_time => undef },
448             {
449                 order_by => { ahr => "request_time" },
450                 flesh => 1,
451                 flesh_fields => {ahr => ['notes']}
452             }
453         ]
454     );
455
456     flesh_hold_transits($holds);
457     flesh_hold_notices($holds, $e);
458     return $holds;
459 }
460
461
462 __PACKAGE__->register_method(
463     method    => "retrieve_holds",
464     api_name  => "open-ils.circ.holds.retrieve",
465     signature => {
466         desc   => "Retrieves all the holds, with hold transits attached, for the specified user.  $ses_is_req_note",
467         params => [
468             { desc => 'Authentication token', type => 'string'  },
469             { desc => 'User ID',              type => 'integer' },
470             { desc => 'Available Only',       type => 'boolean' }
471         ],
472         return => {
473             desc => 'list of holds, event on error',
474         }
475    }
476 );
477
478 __PACKAGE__->register_method(
479     method        => "retrieve_holds",
480     api_name      => "open-ils.circ.holds.id_list.retrieve",
481     authoritative => 1,
482     signature     => {
483         desc   => "Retrieves all the hold IDs, for the specified user.  $ses_is_req_note",
484         params => [
485             { desc => 'Authentication token', type => 'string'  },
486             { desc => 'User ID',              type => 'integer' },
487             { desc => 'Available Only',       type => 'boolean' }
488         ],
489         return => {
490             desc => 'list of holds, event on error',
491         }
492    }
493 );
494
495 __PACKAGE__->register_method(
496     method        => "retrieve_holds",
497     api_name      => "open-ils.circ.holds.canceled.retrieve",
498     authoritative => 1,
499     signature     => {
500         desc   => "Retrieves all the cancelled holds for the specified user.  $ses_is_req_note",
501         params => [
502             { desc => 'Authentication token', type => 'string'  },
503             { desc => 'User ID',              type => 'integer' }
504         ],
505         return => {
506             desc => 'list of holds, event on error',
507         }
508    }
509 );
510
511 __PACKAGE__->register_method(
512     method        => "retrieve_holds",
513     api_name      => "open-ils.circ.holds.canceled.id_list.retrieve",
514     authoritative => 1,
515     signature     => {
516         desc   => "Retrieves list of cancelled hold IDs for the specified user.  $ses_is_req_note",
517         params => [
518             { desc => 'Authentication token', type => 'string'  },
519             { desc => 'User ID',              type => 'integer' }
520         ],
521         return => {
522             desc => 'list of hold IDs, event on error',
523         }
524    }
525 );
526
527
528 sub retrieve_holds {
529     my ($self, $client, $auth, $user_id, $available) = @_;
530
531     my $e = new_editor(authtoken=>$auth);
532     return $e->event unless $e->checkauth;
533     $user_id = $e->requestor->id unless defined $user_id;
534
535     my $notes_filter = {staff => 'f'};
536     my $user = $e->retrieve_actor_user($user_id) or return $e->event;
537     unless($user_id == $e->requestor->id) {
538         if($e->allowed('VIEW_HOLD', $user->home_ou)) {
539             $notes_filter = {staff => 't'}
540         } else {
541             my $allowed = OpenILS::Application::Actor::Friends->friend_perm_allowed(
542                 $e, $user_id, $e->requestor->id, 'hold.view');
543             return $e->event unless $allowed;
544         }
545     } else {
546         # staff member looking at his/her own holds can see staff and non-staff notes
547         $notes_filter = {} if $e->allowed('VIEW_HOLD', $user->home_ou);
548     }
549
550     my $holds_query = {
551         select => {ahr => ['id']},
552         from => 'ahr',
553         where => {usr => $user_id, fulfillment_time => undef}
554     };
555
556     if($self->api_name =~ /canceled/) {
557
558         # Fetch the canceled holds
559         # order cancelled holds by cancel time, most recent first
560
561         $holds_query->{order_by} = [{class => 'ahr', field => 'cancel_time', direction => 'desc'}];
562
563         my $cancel_age;
564         my $cancel_count = $U->ou_ancestor_setting_value(
565                 $e->requestor->ws_ou, 'circ.holds.canceled.display_count', $e);
566
567         unless($cancel_count) {
568             $cancel_age = $U->ou_ancestor_setting_value(
569                 $e->requestor->ws_ou, 'circ.holds.canceled.display_age', $e);
570
571             # if no settings are defined, default to last 10 cancelled holds
572             $cancel_count = 10 unless $cancel_age;
573         }
574
575         if($cancel_count) { # limit by count
576
577             $holds_query->{where}->{cancel_time} = {'!=' => undef};
578             $holds_query->{limit} = $cancel_count;
579
580         } elsif($cancel_age) { # limit by age
581
582             # find all of the canceled holds that were canceled within the configured time frame
583             my $date = DateTime->now->subtract(seconds => OpenSRF::Utils::interval_to_seconds($cancel_age));
584             $date = $U->epoch2ISO8601($date->epoch);
585             $holds_query->{where}->{cancel_time} = {'>=' => $date};
586         }
587
588     } else {
589
590         # order non-cancelled holds by ready-for-pickup, then active, followed by suspended
591         # "compare" sorts false values to the front.  testing pickup_lib != current_shelf_lib
592         # will sort by pl = csl > pl != csl > followed by csl is null;
593         $holds_query->{order_by} = [
594             {   class => 'ahr',
595                 field => 'pickup_lib',
596                 compare => {'!='  => {'+ahr' => 'current_shelf_lib'}}},
597             {class => 'ahr', field => 'shelf_time'},
598             {class => 'ahr', field => 'frozen'},
599             {class => 'ahr', field => 'request_time'}
600
601         ];
602         $holds_query->{where}->{cancel_time} = undef;
603         if($available) {
604             $holds_query->{where}->{shelf_time} = {'!=' => undef};
605             # Maybe?
606             $holds_query->{where}->{pickup_lib} = {'=' => {'+ahr' => 'current_shelf_lib'}};
607         }
608     }
609
610     my $hold_ids = $e->json_query($holds_query);
611     $hold_ids = [ map { $_->{id} } @$hold_ids ];
612
613     return $hold_ids if $self->api_name =~ /id_list/;
614
615     my @holds;
616     for my $hold_id ( @$hold_ids ) {
617
618         my $hold = $e->retrieve_action_hold_request($hold_id);
619         $hold->notes($e->search_action_hold_request_note({hold => $hold_id, %$notes_filter}));
620
621         $hold->transit(
622             $e->search_action_hold_transit_copy([
623                 {hold => $hold->id},
624                 {order_by => {ahtc => 'source_send_time desc'}, limit => 1}])->[0]
625         );
626
627         push(@holds, $hold);
628     }
629
630     return \@holds;
631 }
632
633
634 __PACKAGE__->register_method(
635     method   => 'user_hold_count',
636     api_name => 'open-ils.circ.hold.user.count'
637 );
638
639 sub user_hold_count {
640     my ( $self, $conn, $auth, $userid ) = @_;
641     my $e = new_editor( authtoken => $auth );
642     return $e->event unless $e->checkauth;
643     my $patron = $e->retrieve_actor_user($userid)
644         or return $e->event;
645     return $e->event unless $e->allowed( 'VIEW_HOLD', $patron->home_ou );
646     return __user_hold_count( $self, $e, $userid );
647 }
648
649 sub __user_hold_count {
650     my ( $self, $e, $userid ) = @_;
651     my $holds = $e->search_action_hold_request(
652         {
653             usr              => $userid,
654             fulfillment_time => undef,
655             cancel_time      => undef,
656         },
657         { idlist => 1 }
658     );
659
660     return scalar(@$holds);
661 }
662
663
664 __PACKAGE__->register_method(
665     method   => "retrieve_holds_by_pickup_lib",
666     api_name => "open-ils.circ.holds.retrieve_by_pickup_lib",
667     notes    =>
668       "Retrieves all the holds, with hold transits attached, for the specified pickup_ou id."
669 );
670
671 __PACKAGE__->register_method(
672     method   => "retrieve_holds_by_pickup_lib",
673     api_name => "open-ils.circ.holds.id_list.retrieve_by_pickup_lib",
674     notes    => "Retrieves all the hold ids for the specified pickup_ou id. "
675 );
676
677 sub retrieve_holds_by_pickup_lib {
678     my ($self, $client, $login_session, $ou_id) = @_;
679
680     #FIXME -- put an appropriate permission check here
681     #my( $user, $target, $evt ) = $apputils->checkses_requestor(
682     #    $login_session, $user_id, 'VIEW_HOLD' );
683     #return $evt if $evt;
684
685     my $holds = $apputils->simplereq(
686         'open-ils.cstore',
687         "open-ils.cstore.direct.action.hold_request.search.atomic",
688         {
689             pickup_lib =>  $ou_id ,
690             fulfillment_time => undef,
691             cancel_time => undef
692         },
693         { order_by => { ahr => "request_time" } }
694     );
695
696     if ( ! $self->api_name =~ /id_list/ ) {
697         flesh_hold_transits($holds);
698         return $holds;
699     }
700     # else id_list
701     return [ map { $_->id } @$holds ];
702 }
703
704
705 __PACKAGE__->register_method(
706     method   => "uncancel_hold",
707     api_name => "open-ils.circ.hold.uncancel"
708 );
709
710 sub uncancel_hold {
711     my($self, $client, $auth, $hold_id) = @_;
712     my $e = new_editor(authtoken=>$auth, xact=>1);
713     return $e->die_event unless $e->checkauth;
714
715     my $hold = $e->retrieve_action_hold_request($hold_id)
716         or return $e->die_event;
717     return $e->die_event unless $e->allowed('CANCEL_HOLDS', $hold->request_lib);
718
719     if ($hold->fulfillment_time) {
720         $e->rollback;
721         return 0;
722     }
723     unless ($hold->cancel_time) {
724         $e->rollback;
725         return 1;
726     }
727
728     # if configured to reset the request time, also reset the expire time
729     if($U->ou_ancestor_setting_value(
730         $hold->request_lib, 'circ.holds.uncancel.reset_request_time', $e)) {
731
732         $hold->request_time('now');
733         $hold->expire_time(calculate_expire_time($hold->request_lib));
734     }
735
736     $hold->clear_cancel_time;
737     $hold->clear_cancel_cause;
738     $hold->clear_cancel_note;
739     $hold->clear_shelf_time;
740     $hold->clear_current_copy;
741     $hold->clear_capture_time;
742     $hold->clear_prev_check_time;
743     $hold->clear_shelf_expire_time;
744     $hold->clear_current_shelf_lib;
745
746     $e->update_action_hold_request($hold) or return $e->die_event;
747     $e->commit;
748
749     $U->storagereq('open-ils.storage.action.hold_request.copy_targeter', undef, $hold_id);
750
751     return 1;
752 }
753
754
755 __PACKAGE__->register_method(
756     method    => "cancel_hold",
757     api_name  => "open-ils.circ.hold.cancel",
758     signature => {
759         desc   => 'Cancels the specified hold.  The login session is the requestor.  If the requestor is different from the usr field ' .
760                   'on the hold, the requestor must have CANCEL_HOLDS permissions. The hold may be either the hold object or the hold id',
761         param  => [
762             {desc => 'Authentication token',  type => 'string'},
763             {desc => 'Hold ID',               type => 'number'},
764             {desc => 'Cause of Cancellation', type => 'string'},
765             {desc => 'Note',                  type => 'string'}
766         ],
767         return => {
768             desc => '1 on success, event on error'
769         }
770     }
771 );
772
773 sub cancel_hold {
774     my($self, $client, $auth, $holdid, $cause, $note) = @_;
775
776     my $e = new_editor(authtoken=>$auth, xact=>1);
777     return $e->die_event unless $e->checkauth;
778
779     my $hold = $e->retrieve_action_hold_request($holdid)
780         or return $e->die_event;
781
782     if( $e->requestor->id ne $hold->usr ) {
783         return $e->die_event unless $e->allowed('CANCEL_HOLDS');
784     }
785
786     if ($hold->cancel_time) {
787         $e->rollback;
788         return 1;
789     }
790
791     # If the hold is captured, reset the copy status
792     if( $hold->capture_time and $hold->current_copy ) {
793
794         my $copy = $e->retrieve_asset_copy($hold->current_copy)
795             or return $e->die_event;
796
797         if( $copy->status == OILS_COPY_STATUS_ON_HOLDS_SHELF ) {
798          $logger->info("canceling hold $holdid whose item is on the holds shelf");
799 #            $logger->info("setting copy to status 'reshelving' on hold cancel");
800 #            $copy->status(OILS_COPY_STATUS_RESHELVING);
801 #            $copy->editor($e->requestor->id);
802 #            $copy->edit_date('now');
803 #            $e->update_asset_copy($copy) or return $e->event;
804
805         } elsif( $copy->status == OILS_COPY_STATUS_IN_TRANSIT ) {
806
807             my $hid = $hold->id;
808             $logger->warn("! canceling hold [$hid] that is in transit");
809             my $transid = $e->search_action_hold_transit_copy({hold=>$hold->id},{idlist=>1})->[0];
810
811             if( $transid ) {
812                 my $trans = $e->retrieve_action_transit_copy($transid);
813                 # Leave the transit alive, but  set the copy status to
814                 # reshelving so it will be properly reshelved when it gets back home
815                 if( $trans ) {
816                     $trans->copy_status( OILS_COPY_STATUS_RESHELVING );
817                     $e->update_action_transit_copy($trans) or return $e->die_event;
818                 }
819             }
820         }
821     }
822
823     $hold->cancel_time('now');
824     $hold->cancel_cause($cause);
825     $hold->cancel_note($note);
826     $e->update_action_hold_request($hold)
827         or return $e->die_event;
828
829     $e->commit;
830
831     # re-fetch the hold to pick up the real cancel_time (not "now") for A/T
832     $e->xact_begin;
833     $hold = $e->retrieve_action_hold_request($hold->id) or return $e->die_event;
834     $e->rollback;
835
836     if ($e->requestor->id == $hold->usr) {
837         $U->create_events_for_hook('hold_request.cancel.patron', $hold, $hold->pickup_lib);
838     } else {
839         $U->create_events_for_hook('hold_request.cancel.staff', $hold, $hold->pickup_lib);
840     }
841
842     return 1;
843 }
844
845 my $update_hold_desc = 'The login session is the requestor. '       .
846    'If the requestor is different from the usr field on the hold, ' .
847    'the requestor must have UPDATE_HOLDS permissions. '             .
848    'If supplying a hash of hold data, "id" must be included. '      .
849    'The hash is ignored if a hold object is supplied, '             .
850    'so you should supply only one kind of hold data argument.'      ;
851
852 __PACKAGE__->register_method(
853     method    => "update_hold",
854     api_name  => "open-ils.circ.hold.update",
855     signature => {
856         desc   => "Updates the specified hold.  $update_hold_desc",
857         params => [
858             {desc => 'Authentication token',         type => 'string'},
859             {desc => 'Hold Object',                  type => 'object'},
860             {desc => 'Hash of values to be applied', type => 'object'}
861         ],
862         return => {
863             desc => 'Hold ID on success, event on error',
864             # type => 'number'
865         }
866     }
867 );
868
869 __PACKAGE__->register_method(
870     method    => "batch_update_hold",
871     api_name  => "open-ils.circ.hold.update.batch",
872     stream    => 1,
873     signature => {
874         desc   => "Updates the specified hold(s).  $update_hold_desc",
875         params => [
876             {desc => 'Authentication token',                    type => 'string'},
877             {desc => 'Array of hold obejcts',                   type => 'array' },
878             {desc => 'Array of hashes of values to be applied', type => 'array' }
879         ],
880         return => {
881             desc => 'Hold ID per success, event per error',
882         }
883     }
884 );
885
886 sub update_hold {
887     my($self, $client, $auth, $hold, $values) = @_;
888     my $e = new_editor(authtoken=>$auth, xact=>1);
889     return $e->die_event unless $e->checkauth;
890     my $resp = update_hold_impl($self, $e, $hold, $values);
891     if ($U->event_code($resp)) {
892         $e->rollback;
893         return $resp;
894     }
895     $e->commit;     # FIXME: update_hold_impl already does $e->commit  ??
896     return $resp;
897 }
898
899 sub batch_update_hold {
900     my($self, $client, $auth, $hold_list, $values_list) = @_;
901     my $e = new_editor(authtoken=>$auth);
902     return $e->die_event unless $e->checkauth;
903
904     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.
905     $hold_list   ||= [];
906     $values_list ||= [];      # FIXME: either move this above $count declaration, or send an event if both lists undef.  Probably the latter.
907
908 # FIXME: Failing over to [] guarantees warnings for "Use of unitialized value" in update_hold_impl call.
909 # FIXME: We should be sure we only call update_hold_impl with hold object OR hash, not both.
910
911     for my $idx (0..$count-1) {
912         $e->xact_begin;
913         my $resp = update_hold_impl($self, $e, $hold_list->[$idx], $values_list->[$idx]);
914         $e->xact_commit unless $U->event_code($resp);
915         $client->respond($resp);
916     }
917
918     $e->disconnect;
919     return undef;       # not in the register return type, assuming we should always have at least one list populated
920 }
921
922 sub update_hold_impl {
923     my($self, $e, $hold, $values) = @_;
924     my $hold_status;
925     my $need_retarget = 0;
926
927     unless($hold) {
928         $hold = $e->retrieve_action_hold_request($values->{id})
929             or return $e->die_event;
930         for my $k (keys %$values) {
931             # Outside of pickup_lib (covered by the first regex) I don't know when these would currently change.
932             # But hey, why not cover things that may happen later?
933             if ($k =~ '_(lib|ou)$' || $k eq 'target' || $k eq 'hold_type' || $k eq 'requestor' || $k eq 'selection_depth' || $k eq 'holdable_formats') {
934                 if (defined $values->{$k} && defined $hold->$k() && $values->{$k} ne $hold->$k()) {
935                     # Value changed? RETARGET!
936                     $need_retarget = 1;
937                 } elsif (defined $hold->$k() != defined $values->{$k}) {
938                     # Value being set or cleared? RETARGET!
939                     $need_retarget = 1;
940                 }
941             }
942             if (defined $values->{$k}) {
943                 $hold->$k($values->{$k});
944             } else {
945                 my $f = "clear_$k"; $hold->$f();
946             }
947         }
948     }
949
950     my $orig_hold = $e->retrieve_action_hold_request($hold->id)
951         or return $e->die_event;
952
953     # don't allow the user to be changed
954     return OpenILS::Event->new('BAD_PARAMS') if $hold->usr != $orig_hold->usr;
955
956     if($hold->usr ne $e->requestor->id) {
957         # if the hold is for a different user, make sure the
958         # requestor has the appropriate permissions
959         my $usr = $e->retrieve_actor_user($hold->usr)
960             or return $e->die_event;
961         return $e->die_event unless $e->allowed('UPDATE_HOLD', $usr->home_ou);
962     }
963
964
965     # --------------------------------------------------------------
966     # Changing the request time is like playing God
967     # --------------------------------------------------------------
968     if($hold->request_time ne $orig_hold->request_time) {
969         return OpenILS::Event->new('BAD_PARAMS') if $hold->fulfillment_time;
970         return $e->die_event unless $e->allowed('UPDATE_HOLD_REQUEST_TIME', $hold->pickup_lib);
971     }
972
973
974     # --------------------------------------------------------------
975     # Code for making sure staff have appropriate permissons for cut_in_line
976     # This, as is, doesn't prevent a user from cutting their own holds in line
977     # but needs to
978     # --------------------------------------------------------------
979     if($U->is_true($hold->cut_in_line) ne $U->is_true($orig_hold->cut_in_line)) {
980         return $e->die_event unless $e->allowed('UPDATE_HOLD_REQUEST_TIME', $hold->pickup_lib);
981     }
982
983
984     # --------------------------------------------------------------
985     # Disallow hold suspencion if the hold is already captured.
986     # --------------------------------------------------------------
987     if ($U->is_true($hold->frozen) and not $U->is_true($orig_hold->frozen)) {
988         $hold_status = _hold_status($e, $hold);
989         if ($hold_status > 2 && $hold_status != 7) { # hold is captured
990             $logger->info("bypassing hold freeze on captured hold");
991             return OpenILS::Event->new('HOLD_SUSPEND_AFTER_CAPTURE');
992         }
993     }
994
995
996     # --------------------------------------------------------------
997     # if the hold is on the holds shelf or in transit and the pickup
998     # lib changes we need to create a new transit.
999     # --------------------------------------------------------------
1000     if($orig_hold->pickup_lib ne $hold->pickup_lib) {
1001
1002         $hold_status = _hold_status($e, $hold) unless $hold_status;
1003
1004         if($hold_status == 3) { # in transit
1005
1006             return $e->die_event unless $e->allowed('UPDATE_PICKUP_LIB_FROM_TRANSIT', $orig_hold->pickup_lib);
1007             return $e->die_event unless $e->allowed('UPDATE_PICKUP_LIB_FROM_TRANSIT', $hold->pickup_lib);
1008
1009             $logger->info("updating pickup lib for hold ".$hold->id." while already in transit");
1010
1011             # update the transit to reflect the new pickup location
1012             my $transit = $e->search_action_hold_transit_copy(
1013                 {hold=>$hold->id, dest_recv_time => undef})->[0]
1014                 or return $e->die_event;
1015
1016             $transit->prev_dest($transit->dest); # mark the previous destination on the transit
1017             $transit->dest($hold->pickup_lib);
1018             $e->update_action_hold_transit_copy($transit) or return $e->die_event;
1019
1020         } elsif($hold_status == 4 or $hold_status == 5 or $hold_status == 8) { # on holds shelf
1021
1022             return $e->die_event unless $e->allowed('UPDATE_PICKUP_LIB_FROM_HOLDS_SHELF', $orig_hold->pickup_lib);
1023             return $e->die_event unless $e->allowed('UPDATE_PICKUP_LIB_FROM_HOLDS_SHELF', $hold->pickup_lib);
1024
1025             $logger->info("updating pickup lib for hold ".$hold->id." while on holds shelf");
1026
1027             if ($hold->pickup_lib eq $orig_hold->current_shelf_lib) {
1028                 # This can happen if the pickup lib is changed while the hold is
1029                 # on the shelf, then changed back to the original pickup lib.
1030                 # Restore the original shelf_expire_time to prevent abuse.
1031                 set_hold_shelf_expire_time(undef, $hold, $e, $hold->shelf_time);
1032
1033             } else {
1034                 # clear to prevent premature shelf expiration
1035                 $hold->clear_shelf_expire_time;
1036             }
1037         }
1038     }
1039
1040     if($U->is_true($hold->frozen)) {
1041         $logger->info("clearing current_copy and check_time for frozen hold ".$hold->id);
1042         $hold->clear_current_copy;
1043         $hold->clear_prev_check_time;
1044         # Clear expire_time to prevent frozen holds from expiring.
1045         $logger->info("clearing expire_time for frozen hold ".$hold->id);
1046         $hold->clear_expire_time;
1047     }
1048
1049     # If the hold_expire_time is in the past && is not equal to the
1050     # original expire_time, then reset the expire time to be in the
1051     # future.
1052     if ($hold->expire_time && $U->datecmp($hold->expire_time) == -1 && $U->datecmp($hold->expire_time, $orig_hold->expire_time) != 0) {
1053         $hold->expire_time(calculate_expire_time($hold->request_lib));
1054     }
1055
1056     # If the hold is reactivated, reset the expire_time.
1057     if(!$U->is_true($hold->frozen) && $U->is_true($orig_hold->frozen)) {
1058         $logger->info("Reset expire_time on activated hold ".$hold->id);
1059         $hold->expire_time(calculate_expire_time($hold->request_lib));
1060     }
1061
1062     $e->update_action_hold_request($hold) or return $e->die_event;
1063     $e->commit;
1064
1065     if(!$U->is_true($hold->frozen) && $U->is_true($orig_hold->frozen)) {
1066         $logger->info("Running targeter on activated hold ".$hold->id);
1067         $U->storagereq( 'open-ils.storage.action.hold_request.copy_targeter', undef, $hold->id );
1068     }
1069
1070     # a change to mint-condition changes the set of potential copies, so retarget the hold;
1071     if($U->is_true($hold->mint_condition) and !$U->is_true($orig_hold->mint_condition)) {
1072         _reset_hold($self, $e->requestor, $hold)
1073     } elsif($need_retarget && !defined $hold->capture_time()) { # If needed, retarget the hold due to changes
1074         $U->storagereq(
1075             'open-ils.storage.action.hold_request.copy_targeter', undef, $hold->id );
1076     }
1077
1078     return $hold->id;
1079 }
1080
1081 # this does not update the hold in the DB.  It only
1082 # sets the shelf_expire_time field on the hold object.
1083 # start_time is optional and defaults to 'now'
1084 sub set_hold_shelf_expire_time {
1085     my ($class, $hold, $editor, $start_time) = @_;
1086
1087     my $shelf_expire = $U->ou_ancestor_setting_value(
1088         $hold->pickup_lib,
1089         'circ.holds.default_shelf_expire_interval',
1090         $editor
1091     );
1092
1093     return undef unless $shelf_expire;
1094
1095     $start_time = ($start_time) ?
1096         DateTime::Format::ISO8601->new->parse_datetime(cleanse_ISO8601($start_time)) :
1097         DateTime->now(time_zone => 'local'); # without time_zone we get UTC ... yuck!
1098
1099     my $seconds = OpenSRF::Utils->interval_to_seconds($shelf_expire);
1100     my $expire_time = $start_time->add(seconds => $seconds);
1101
1102     # if the shelf expire time overlaps with a pickup lib's
1103     # closed date, push it out to the first open date
1104     my $dateinfo = $U->storagereq(
1105         'open-ils.storage.actor.org_unit.closed_date.overlap',
1106         $hold->pickup_lib, $expire_time->strftime('%FT%T%z'));
1107
1108     if($dateinfo) {
1109         my $dt_parser = DateTime::Format::ISO8601->new;
1110         $expire_time = $dt_parser->parse_datetime(cleanse_ISO8601($dateinfo->{end}));
1111
1112         # TODO: enable/disable time bump via setting?
1113         $expire_time->set(hour => '23', minute => '59', second => '59');
1114
1115         $logger->info("circulator: shelf_expire_time overlaps".
1116             " with closed date, pushing expire time to $expire_time");
1117     }
1118
1119     $hold->shelf_expire_time($expire_time->strftime('%FT%T%z'));
1120     return undef;
1121 }
1122
1123
1124 sub transit_hold {
1125     my($e, $orig_hold, $hold, $copy) = @_;
1126     my $src  = $orig_hold->pickup_lib;
1127     my $dest = $hold->pickup_lib;
1128
1129     $logger->info("putting hold into transit on pickup_lib update");
1130
1131     my $transit = Fieldmapper::action::hold_transit_copy->new;
1132     $transit->hold($hold->id);
1133     $transit->source($src);
1134     $transit->dest($dest);
1135     $transit->target_copy($copy->id);
1136     $transit->source_send_time('now');
1137     $transit->copy_status(OILS_COPY_STATUS_ON_HOLDS_SHELF);
1138
1139     $copy->status(OILS_COPY_STATUS_IN_TRANSIT);
1140     $copy->editor($e->requestor->id);
1141     $copy->edit_date('now');
1142
1143     $e->create_action_hold_transit_copy($transit) or return $e->die_event;
1144     $e->update_asset_copy($copy) or return $e->die_event;
1145     return undef;
1146 }
1147
1148 # if the hold is frozen, this method ensures that the hold is not "targeted",
1149 # that is, it clears the current_copy and prev_check_time to essentiallly
1150 # reset the hold.  If it is being activated, it runs the targeter in the background
1151 sub update_hold_if_frozen {
1152     my($self, $e, $hold, $orig_hold) = @_;
1153     return if $hold->capture_time;
1154
1155     if($U->is_true($hold->frozen)) {
1156         $logger->info("clearing current_copy and check_time for frozen hold ".$hold->id);
1157         $hold->clear_current_copy;
1158         $hold->clear_prev_check_time;
1159
1160     } else {
1161         if($U->is_true($orig_hold->frozen)) {
1162             $logger->info("Running targeter on activated hold ".$hold->id);
1163             $U->storagereq( 'open-ils.storage.action.hold_request.copy_targeter', undef, $hold->id );
1164         }
1165     }
1166 }
1167
1168 __PACKAGE__->register_method(
1169     method    => "hold_note_CUD",
1170     api_name  => "open-ils.circ.hold_request.note.cud",
1171     signature => {
1172         desc   => 'Create, update or delete a hold request note.  If the operator (from Auth. token) '
1173                 . 'is not the owner of the hold, the UPDATE_HOLD permission is required',
1174         params => [
1175             { desc => 'Authentication token', type => 'string' },
1176             { desc => 'Hold note object',     type => 'object' }
1177         ],
1178         return => {
1179             desc => 'Returns the note ID, event on error'
1180         },
1181     }
1182 );
1183
1184 sub hold_note_CUD {
1185     my($self, $conn, $auth, $note) = @_;
1186
1187     my $e = new_editor(authtoken => $auth, xact => 1);
1188     return $e->die_event unless $e->checkauth;
1189
1190     my $hold = $e->retrieve_action_hold_request($note->hold)
1191         or return $e->die_event;
1192
1193     if($hold->usr ne $e->requestor->id) {
1194         my $usr = $e->retrieve_actor_user($hold->usr);
1195         return $e->die_event unless $e->allowed('UPDATE_HOLD', $usr->home_ou);
1196         $note->staff('t') if $note->isnew;
1197     }
1198
1199     if($note->isnew) {
1200         $e->create_action_hold_request_note($note) or return $e->die_event;
1201     } elsif($note->ischanged) {
1202         $e->update_action_hold_request_note($note) or return $e->die_event;
1203     } elsif($note->isdeleted) {
1204         $e->delete_action_hold_request_note($note) or return $e->die_event;
1205     }
1206
1207     $e->commit;
1208     return $note->id;
1209 }
1210
1211
1212 __PACKAGE__->register_method(
1213     method    => "retrieve_hold_status",
1214     api_name  => "open-ils.circ.hold.status.retrieve",
1215     signature => {
1216         desc   => 'Calculates the current status of the hold. The requestor must have '      .
1217                   'VIEW_HOLD permissions if the hold is for a user other than the requestor' ,
1218         param  => [
1219             { desc => 'Hold ID', type => 'number' }
1220         ],
1221         return => {
1222             # type => 'number',     # event sometimes
1223             desc => <<'END_OF_DESC'
1224 Returns event on error or:
1225 -1 on error (for now),
1226  1 for 'waiting for copy to become available',
1227  2 for 'waiting for copy capture',
1228  3 for 'in transit',
1229  4 for 'arrived',
1230  5 for 'hold-shelf-delay'
1231  6 for 'canceled'
1232  7 for 'suspended'
1233  8 for 'captured, on wrong hold shelf'
1234 END_OF_DESC
1235         }
1236     }
1237 );
1238
1239 sub retrieve_hold_status {
1240     my($self, $client, $auth, $hold_id) = @_;
1241
1242     my $e = new_editor(authtoken => $auth);
1243     return $e->event unless $e->checkauth;
1244     my $hold = $e->retrieve_action_hold_request($hold_id)
1245         or return $e->event;
1246
1247     if( $e->requestor->id != $hold->usr ) {
1248         return $e->event unless $e->allowed('VIEW_HOLD');
1249     }
1250
1251     return _hold_status($e, $hold);
1252
1253 }
1254
1255 sub _hold_status {
1256     my($e, $hold) = @_;
1257     if ($hold->cancel_time) {
1258         return 6;
1259     }
1260     if ($U->is_true($hold->frozen) && !$hold->capture_time) {
1261         return 7;
1262     }
1263     if ($hold->current_shelf_lib and $hold->current_shelf_lib ne $hold->pickup_lib) {
1264         return 8;
1265     }
1266     return 1 unless $hold->current_copy;
1267     return 2 unless $hold->capture_time;
1268
1269     my $copy = $hold->current_copy;
1270     unless( ref $copy ) {
1271         $copy = $e->retrieve_asset_copy($hold->current_copy)
1272             or return $e->event;
1273     }
1274
1275     return 3 if $copy->status == OILS_COPY_STATUS_IN_TRANSIT;
1276
1277     if($copy->status == OILS_COPY_STATUS_ON_HOLDS_SHELF) {
1278
1279         my $hs_wait_interval = $U->ou_ancestor_setting_value($hold->pickup_lib, 'circ.hold_shelf_status_delay');
1280         return 4 unless $hs_wait_interval;
1281
1282         # if a hold_shelf_status_delay interval is defined and start_time plus
1283         # the interval is greater than now, consider the hold to be in the virtual
1284         # "on its way to the holds shelf" status. Return 5.
1285
1286         my $transit    = $e->search_action_hold_transit_copy({
1287                             hold           => $hold->id,
1288                             target_copy    => $copy->id,
1289                             dest_recv_time => {'!=' => undef},
1290                          })->[0];
1291         my $start_time = ($transit) ? $transit->dest_recv_time : $hold->capture_time;
1292         $start_time    = DateTime::Format::ISO8601->new->parse_datetime(cleanse_ISO8601($start_time));
1293         my $end_time   = $start_time->add(seconds => OpenSRF::Utils::interval_to_seconds($hs_wait_interval));
1294
1295         return 5 if $end_time > DateTime->now;
1296         return 4;
1297     }
1298
1299     return -1;  # error
1300 }
1301
1302
1303
1304 __PACKAGE__->register_method(
1305     method    => "retrieve_hold_queue_stats",
1306     api_name  => "open-ils.circ.hold.queue_stats.retrieve",
1307     signature => {
1308         desc   => 'Returns summary data about the state of a hold',
1309         params => [
1310             { desc => 'Authentication token',  type => 'string'},
1311             { desc => 'Hold ID', type => 'number'},
1312         ],
1313         return => {
1314             desc => q/Summary object with keys:
1315                 total_holds : total holds in queue
1316                 queue_position : current queue position
1317                 potential_copies : number of potential copies for this hold
1318                 estimated_wait : estimated wait time in days
1319                 status : hold status
1320                      -1 => error or unexpected state,
1321                      1 => 'waiting for copy to become available',
1322                      2 => 'waiting for copy capture',
1323                      3 => 'in transit',
1324                      4 => 'arrived',
1325                      5 => 'hold-shelf-delay'
1326             /,
1327             type => 'object'
1328         }
1329     }
1330 );
1331
1332 sub retrieve_hold_queue_stats {
1333     my($self, $conn, $auth, $hold_id) = @_;
1334     my $e = new_editor(authtoken => $auth);
1335     return $e->event unless $e->checkauth;
1336     my $hold = $e->retrieve_action_hold_request($hold_id) or return $e->event;
1337     if($e->requestor->id != $hold->usr) {
1338         return $e->event unless $e->allowed('VIEW_HOLD');
1339     }
1340     return retrieve_hold_queue_status_impl($e, $hold);
1341 }
1342
1343 sub retrieve_hold_queue_status_impl {
1344     my $e = shift;
1345     my $hold = shift;
1346
1347     # The holds queue is defined as the distinct set of holds that share at
1348     # least one potential copy with the context hold, plus any holds that
1349     # share the same hold type and target.  The latter part exists to
1350     # accomodate holds that currently have no potential copies
1351     my $q_holds = $e->json_query({
1352
1353         # fetch cut_in_line and request_time since they're in the order_by
1354         # and we're asking for distinct values
1355         select => {ahr => ['id', 'cut_in_line', 'request_time']},
1356         from   => 'ahr',
1357         where => {
1358             id => { in => {
1359                 select => { ahcm => ['hold'] },
1360                 from   => {
1361                     'ahcm' => {
1362                         'ahcm2' => {
1363                             'class' => 'ahcm',
1364                             'field' => 'target_copy',
1365                             'fkey'  => 'target_copy'
1366                         }
1367                     }
1368                 },
1369                 where => { '+ahcm2' => { hold => $hold->id } },
1370                 distinct => 1
1371             }}
1372         },
1373         order_by => [
1374             {
1375                 "class" => "ahr",
1376                 "field" => "cut_in_line",
1377                 "transform" => "coalesce",
1378                 "params" => [ 0 ],
1379                 "direction" => "desc"
1380             },
1381             { "class" => "ahr", "field" => "request_time" }
1382         ],
1383         distinct => 1
1384     });
1385
1386     if (!@$q_holds) { # none? maybe we don't have a map ...
1387         $q_holds = $e->json_query({
1388             select => {ahr => ['id', 'cut_in_line', 'request_time']},
1389             from   => 'ahr',
1390             order_by => [
1391                 {
1392                     "class" => "ahr",
1393                     "field" => "cut_in_line",
1394                     "transform" => "coalesce",
1395                     "params" => [ 0 ],
1396                     "direction" => "desc"
1397                 },
1398                 { "class" => "ahr", "field" => "request_time" }
1399             ],
1400             where    => {
1401                 hold_type => $hold->hold_type,
1402                 target    => $hold->target,
1403                 capture_time => undef,
1404                 cancel_time => undef,
1405                 '-or' => [
1406                     {expire_time => undef },
1407                     {expire_time => {'>' => 'now'}}
1408                 ]
1409            }
1410         });
1411     }
1412
1413
1414     my $qpos = 1;
1415     for my $h (@$q_holds) {
1416         last if $h->{id} == $hold->id;
1417         $qpos++;
1418     }
1419
1420     my $hold_data = $e->json_query({
1421         select => {
1422             acp => [ {column => 'id', transform => 'count', aggregate => 1, alias => 'count'} ],
1423             ccm => [ {column =>'avg_wait_time'} ]
1424         },
1425         from => {
1426             ahcm => {
1427                 acp => {
1428                     join => {
1429                         ccm => {type => 'left'}
1430                     }
1431                 }
1432             }
1433         },
1434         where => {'+ahcm' => {hold => $hold->id} }
1435     });
1436
1437     my $user_org = $e->json_query({select => {au => ['home_ou']}, from => 'au', where => {id => $hold->usr}})->[0]->{home_ou};
1438
1439     my $default_wait = $U->ou_ancestor_setting_value($user_org, OILS_SETTING_HOLD_ESIMATE_WAIT_INTERVAL);
1440     my $min_wait = $U->ou_ancestor_setting_value($user_org, 'circ.holds.min_estimated_wait_interval');
1441     $min_wait = OpenSRF::Utils::interval_to_seconds($min_wait || '0 seconds');
1442     $default_wait ||= '0 seconds';
1443
1444     # Estimated wait time is the average wait time across the set
1445     # of potential copies, divided by the number of potential copies
1446     # times the queue position.
1447
1448     my $combined_secs = 0;
1449     my $num_potentials = 0;
1450
1451     for my $wait_data (@$hold_data) {
1452         my $count += $wait_data->{count};
1453         $combined_secs += $count *
1454             OpenSRF::Utils::interval_to_seconds($wait_data->{avg_wait_time} || $default_wait);
1455         $num_potentials += $count;
1456     }
1457
1458     my $estimated_wait = -1;
1459
1460     if($num_potentials) {
1461         my $avg_wait = $combined_secs / $num_potentials;
1462         $estimated_wait = $qpos * ($avg_wait / $num_potentials);
1463         $estimated_wait = $min_wait if $estimated_wait < $min_wait and $estimated_wait != -1;
1464     }
1465
1466     return {
1467         total_holds      => scalar(@$q_holds),
1468         queue_position   => $qpos,
1469         potential_copies => $num_potentials,
1470         status           => _hold_status( $e, $hold ),
1471         estimated_wait   => int($estimated_wait)
1472     };
1473 }
1474
1475
1476 sub fetch_open_hold_by_current_copy {
1477     my $class = shift;
1478     my $copyid = shift;
1479     my $hold = $apputils->simplereq(
1480         'open-ils.cstore',
1481         'open-ils.cstore.direct.action.hold_request.search.atomic',
1482         { current_copy =>  $copyid , cancel_time => undef, fulfillment_time => undef });
1483     return $hold->[0] if ref($hold);
1484     return undef;
1485 }
1486
1487 sub fetch_related_holds {
1488     my $class = shift;
1489     my $copyid = shift;
1490     return $apputils->simplereq(
1491         'open-ils.cstore',
1492         'open-ils.cstore.direct.action.hold_request.search.atomic',
1493         { current_copy =>  $copyid , cancel_time => undef, fulfillment_time => undef });
1494 }
1495
1496
1497 __PACKAGE__->register_method(
1498     method    => "hold_pull_list",
1499     api_name  => "open-ils.circ.hold_pull_list.retrieve",
1500     signature => {
1501         desc   => 'Returns (reference to) a list of holds that need to be "pulled" by a given location. ' .
1502                   'The location is determined by the login session.',
1503         params => [
1504             { desc => 'Limit (optional)',  type => 'number'},
1505             { desc => 'Offset (optional)', type => 'number'},
1506         ],
1507         return => {
1508             desc => 'reference to a list of holds, or event on failure',
1509         }
1510     }
1511 );
1512
1513 __PACKAGE__->register_method(
1514     method    => "hold_pull_list",
1515     api_name  => "open-ils.circ.hold_pull_list.id_list.retrieve",
1516     signature => {
1517         desc   => 'Returns (reference to) a list of holds IDs that need to be "pulled" by a given location. ' .
1518                   'The location is determined by the login session.',
1519         params => [
1520             { desc => 'Limit (optional)',  type => 'number'},
1521             { desc => 'Offset (optional)', type => 'number'},
1522         ],
1523         return => {
1524             desc => 'reference to a list of holds, or event on failure',
1525         }
1526     }
1527 );
1528
1529 __PACKAGE__->register_method(
1530     method    => "hold_pull_list",
1531     api_name  => "open-ils.circ.hold_pull_list.retrieve.count",
1532     signature => {
1533         desc   => 'Returns a count of holds that need to be "pulled" by a given location. ' .
1534                   'The location is determined by the login session.',
1535         params => [
1536             { desc => 'Limit (optional)',  type => 'number'},
1537             { desc => 'Offset (optional)', type => 'number'},
1538         ],
1539         return => {
1540             desc => 'Holds count (integer), or event on failure',
1541             # type => 'number'
1542         }
1543     }
1544 );
1545
1546 __PACKAGE__->register_method(
1547     method    => "hold_pull_list",
1548     stream => 1,
1549     # TODO: tag with api_level 2 once fully supported
1550     api_name  => "open-ils.circ.hold_pull_list.fleshed.stream",
1551     signature => {
1552         desc   => q/Returns a stream of fleshed holds  that need to be 
1553                     "pulled" by a given location.  The location is 
1554                     determined by the login session.  
1555                     This API calls always run in authoritative mode./,
1556         params => [
1557             { desc => 'Limit (optional)',  type => 'number'},
1558             { desc => 'Offset (optional)', type => 'number'},
1559         ],
1560         return => {
1561             desc => 'Stream of holds holds, or event on failure',
1562         }
1563     }
1564 );
1565
1566 sub hold_pull_list {
1567     my( $self, $conn, $authtoken, $limit, $offset ) = @_;
1568     my( $reqr, $evt ) = $U->checkses($authtoken);
1569     return $evt if $evt;
1570
1571     my $org = $reqr->ws_ou || $reqr->home_ou;
1572     # the perm locaiton shouldn't really matter here since holds
1573     # will exist all over and VIEW_HOLDS should be universal
1574     $evt = $U->check_perms($reqr->id, $org, 'VIEW_HOLD');
1575     return $evt if $evt;
1576
1577     if($self->api_name =~ /count/) {
1578
1579         my $count = $U->storagereq(
1580             'open-ils.storage.direct.action.hold_request.pull_list.current_copy_circ_lib.status_filtered.count',
1581             $org, $limit, $offset );
1582
1583         $logger->info("Grabbing pull list for org unit $org with $count items");
1584         return $count;
1585
1586     } elsif( $self->api_name =~ /id_list/ ) {
1587         $U->storagereq(
1588             'open-ils.storage.direct.action.hold_request.pull_list.id_list.current_copy_circ_lib.status_filtered.atomic',
1589             $org, $limit, $offset );
1590
1591     } elsif ($self->api_name =~ /fleshed/) {
1592
1593         my $ids = $U->storagereq(
1594             'open-ils.storage.direct.action.hold_request.pull_list.id_list.current_copy_circ_lib.status_filtered.atomic',
1595             $org, $limit, $offset );
1596
1597         my $e = new_editor(xact => 1, requestor => $reqr);
1598         $conn->respond(uber_hold_impl($e, $_, {flesh_acpl => 1})) for @$ids;
1599         $e->rollback;
1600         $conn->respond_complete;
1601         return;
1602
1603     } else {
1604         $U->storagereq(
1605             'open-ils.storage.direct.action.hold_request.pull_list.search.current_copy_circ_lib.status_filtered.atomic',
1606             $org, $limit, $offset );
1607     }
1608 }
1609
1610 __PACKAGE__->register_method(
1611     method    => "print_hold_pull_list",
1612     api_name  => "open-ils.circ.hold_pull_list.print",
1613     signature => {
1614         desc   => 'Returns an HTML-formatted holds pull list',
1615         params => [
1616             { desc => 'Authtoken', type => 'string'},
1617             { desc => 'Org unit ID.  Optional, defaults to workstation org unit', type => 'number'},
1618         ],
1619         return => {
1620             desc => 'HTML string',
1621             type => 'string'
1622         }
1623     }
1624 );
1625
1626 sub print_hold_pull_list {
1627     my($self, $client, $auth, $org_id) = @_;
1628
1629     my $e = new_editor(authtoken=>$auth);
1630     return $e->event unless $e->checkauth;
1631
1632     $org_id = (defined $org_id) ? $org_id : $e->requestor->ws_ou;
1633     return $e->event unless $e->allowed('VIEW_HOLD', $org_id);
1634
1635     my $hold_ids = $U->storagereq(
1636         'open-ils.storage.direct.action.hold_request.pull_list.id_list.current_copy_circ_lib.status_filtered.atomic',
1637         $org_id, 10000);
1638
1639     return undef unless @$hold_ids;
1640
1641     $client->status(new OpenSRF::DomainObject::oilsContinueStatus);
1642
1643     # Holds will /NOT/ be in order after this ...
1644     my $holds = $e->search_action_hold_request({id => $hold_ids}, {substream => 1});
1645     $client->status(new OpenSRF::DomainObject::oilsContinueStatus);
1646
1647     # ... so we must resort.
1648     my $hold_map = +{map { $_->id => $_ } @$holds};
1649     my $sorted_holds = [];
1650     push @$sorted_holds, $hold_map->{$_} foreach @$hold_ids;
1651
1652     return $U->fire_object_event(
1653         undef, "ahr.format.pull_list", $sorted_holds,
1654         $org_id, undef, undef, $client
1655     );
1656
1657 }
1658
1659 __PACKAGE__->register_method(
1660     method    => "print_hold_pull_list_stream",
1661     stream   => 1,
1662     api_name  => "open-ils.circ.hold_pull_list.print.stream",
1663     signature => {
1664         desc   => 'Returns a stream of fleshed holds',
1665         params => [
1666             { desc => 'Authtoken', type => 'string'},
1667             { 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)',
1668               type => 'object'
1669             },
1670         ],
1671         return => {
1672             desc => 'A stream of fleshed holds',
1673             type => 'object'
1674         }
1675     }
1676 );
1677
1678 sub print_hold_pull_list_stream {
1679     my($self, $client, $auth, $params) = @_;
1680
1681     my $e = new_editor(authtoken=>$auth);
1682     return $e->die_event unless $e->checkauth;
1683
1684     delete($$params{org_id}) unless (int($$params{org_id}));
1685     delete($$params{limit}) unless (int($$params{limit}));
1686     delete($$params{offset}) unless (int($$params{offset}));
1687     delete($$params{chunk_size}) unless (int($$params{chunk_size}));
1688     delete($$params{chunk_size}) if  ($$params{chunk_size} && $$params{chunk_size} > 50); # keep the size reasonable
1689     $$params{chunk_size} ||= 10;
1690     $client->max_chunk_size($$params{chunk_size});
1691
1692     $$params{org_id} = (defined $$params{org_id}) ? $$params{org_id}: $e->requestor->ws_ou;
1693     return $e->die_event unless $e->allowed('VIEW_HOLD', $$params{org_id });
1694
1695     my $sort = [];
1696     if ($$params{sort} && @{ $$params{sort} }) {
1697         for my $s (@{ $$params{sort} }) {
1698             if ($s eq 'acplo.position') {
1699                 push @$sort, {
1700                     "class" => "acplo", "field" => "position",
1701                     "transform" => "coalesce", "params" => [999]
1702                 };
1703             } elsif ($s eq 'prefix') {
1704                 push @$sort, {"class" => "acnp", "field" => "label_sortkey"};
1705             } elsif ($s eq 'call_number') {
1706                 push @$sort, {"class" => "acn", "field" => "label_sortkey"};
1707             } elsif ($s eq 'suffix') {
1708                 push @$sort, {"class" => "acns", "field" => "label_sortkey"};
1709             } elsif ($s eq 'request_time') {
1710                 push @$sort, {"class" => "ahr", "field" => "request_time"};
1711             }
1712         }
1713     } else {
1714         push @$sort, {"class" => "ahr", "field" => "request_time"};
1715     }
1716
1717     my $holds_ids = $e->json_query(
1718         {
1719             "select" => {"ahr" => ["id"]},
1720             "from" => {
1721                 "ahr" => {
1722                     "acp" => {
1723                         "field" => "id",
1724                         "fkey" => "current_copy",
1725                         "filter" => {
1726                             "circ_lib" => $$params{org_id}, "status" => [0,7]
1727                         },
1728                         "join" => {
1729                             "acn" => {
1730                                 "field" => "id",
1731                                 "fkey" => "call_number",
1732                                 "join" => {
1733                                     "acnp" => {
1734                                         "field" => "id",
1735                                         "fkey" => "prefix"
1736                                     },
1737                                     "acns" => {
1738                                         "field" => "id",
1739                                         "fkey" => "suffix"
1740                                     }
1741                                 }
1742                             },
1743                             "acplo" => {
1744                                 "field" => "org",
1745                                 "fkey" => "circ_lib",
1746                                 "type" => "left",
1747                                 "filter" => {
1748                                     "location" => {"=" => {"+acp" => "location"}}
1749                                 }
1750                             }
1751                         }
1752                     }
1753                 }
1754             },
1755             "where" => {
1756                 "+ahr" => {
1757                     "capture_time" => undef,
1758                     "cancel_time" => undef,
1759                     "-or" => [
1760                         {"expire_time" => undef },
1761                         {"expire_time" => {">" => "now"}}
1762                     ]
1763                 }
1764             },
1765             (@$sort ? (order_by => $sort) : ()),
1766             ($$params{limit} ? (limit => $$params{limit}) : ()),
1767             ($$params{offset} ? (offset => $$params{offset}) : ())
1768         }, {"substream" => 1}
1769     ) or return $e->die_event;
1770
1771     $logger->info("about to stream back " . scalar(@$holds_ids) . " holds");
1772
1773     my @chunk;
1774     for my $hid (@$holds_ids) {
1775         push @chunk, $e->retrieve_action_hold_request([
1776             $hid->{"id"}, {
1777                 "flesh" => 3,
1778                 "flesh_fields" => {
1779                     "ahr" => ["usr", "current_copy"],
1780                     "au"  => ["card"],
1781                     "acp" => ["location", "call_number", "parts"],
1782                     "acn" => ["record","prefix","suffix"]
1783                 }
1784             }
1785         ]);
1786
1787         if (@chunk >= $$params{chunk_size}) {
1788             $client->respond( \@chunk );
1789             @chunk = ();
1790         }
1791     }
1792     $client->respond_complete( \@chunk ) if (@chunk);
1793     $e->disconnect;
1794     return undef;
1795 }
1796
1797
1798
1799 __PACKAGE__->register_method(
1800     method        => 'fetch_hold_notify',
1801     api_name      => 'open-ils.circ.hold_notification.retrieve_by_hold',
1802     authoritative => 1,
1803     signature     => q/
1804 Returns a list of hold notification objects based on hold id.
1805 @param authtoken The loggin session key
1806 @param holdid The id of the hold whose notifications we want to retrieve
1807 @return An array of hold notification objects, event on error.
1808 /
1809 );
1810
1811 sub fetch_hold_notify {
1812     my( $self, $conn, $authtoken, $holdid ) = @_;
1813     my( $requestor, $evt ) = $U->checkses($authtoken);
1814     return $evt if $evt;
1815     my ($hold, $patron);
1816     ($hold, $evt) = $U->fetch_hold($holdid);
1817     return $evt if $evt;
1818     ($patron, $evt) = $U->fetch_user($hold->usr);
1819     return $evt if $evt;
1820
1821     $evt = $U->check_perms($requestor->id, $patron->home_ou, 'VIEW_HOLD_NOTIFICATION');
1822     return $evt if $evt;
1823
1824     $logger->info("User ".$requestor->id." fetching hold notifications for hold $holdid");
1825     return $U->cstorereq(
1826         'open-ils.cstore.direct.action.hold_notification.search.atomic', {hold => $holdid} );
1827 }
1828
1829
1830 __PACKAGE__->register_method(
1831     method    => 'create_hold_notify',
1832     api_name  => 'open-ils.circ.hold_notification.create',
1833     signature => q/
1834 Creates a new hold notification object
1835 @param authtoken The login session key
1836 @param notification The hold notification object to create
1837 @return ID of the new object on success, Event on error
1838 /
1839 );
1840
1841 sub create_hold_notify {
1842    my( $self, $conn, $auth, $note ) = @_;
1843    my $e = new_editor(authtoken=>$auth, xact=>1);
1844    return $e->die_event unless $e->checkauth;
1845
1846    my $hold = $e->retrieve_action_hold_request($note->hold)
1847       or return $e->die_event;
1848    my $patron = $e->retrieve_actor_user($hold->usr)
1849       or return $e->die_event;
1850
1851    return $e->die_event unless
1852       $e->allowed('CREATE_HOLD_NOTIFICATION', $patron->home_ou);
1853
1854    $note->notify_staff($e->requestor->id);
1855    $e->create_action_hold_notification($note) or return $e->die_event;
1856    $e->commit;
1857    return $note->id;
1858 }
1859
1860 __PACKAGE__->register_method(
1861     method    => 'create_hold_note',
1862     api_name  => 'open-ils.circ.hold_note.create',
1863     signature => q/
1864         Creates a new hold request note object
1865         @param authtoken The login session key
1866         @param note The hold note object to create
1867         @return ID of the new object on success, Event on error
1868         /
1869 );
1870
1871 sub create_hold_note {
1872    my( $self, $conn, $auth, $note ) = @_;
1873    my $e = new_editor(authtoken=>$auth, xact=>1);
1874    return $e->die_event unless $e->checkauth;
1875
1876    my $hold = $e->retrieve_action_hold_request($note->hold)
1877       or return $e->die_event;
1878    my $patron = $e->retrieve_actor_user($hold->usr)
1879       or return $e->die_event;
1880
1881    return $e->die_event unless
1882       $e->allowed('UPDATE_HOLD', $patron->home_ou); # FIXME: Using permcrud perm listed in fm_IDL.xml for ahrn.  Probably want something more specific
1883
1884    $e->create_action_hold_request_note($note) or return $e->die_event;
1885    $e->commit;
1886    return $note->id;
1887 }
1888
1889 __PACKAGE__->register_method(
1890     method    => 'reset_hold',
1891     api_name  => 'open-ils.circ.hold.reset',
1892     signature => q/
1893         Un-captures and un-targets a hold, essentially returning
1894         it to the state it was in directly after it was placed,
1895         then attempts to re-target the hold
1896         @param authtoken The login session key
1897         @param holdid The id of the hold
1898     /
1899 );
1900
1901
1902 sub reset_hold {
1903     my( $self, $conn, $auth, $holdid ) = @_;
1904     my $reqr;
1905     my ($hold, $evt) = $U->fetch_hold($holdid);
1906     return $evt if $evt;
1907     ($reqr, $evt) = $U->checksesperm($auth, 'UPDATE_HOLD');
1908     return $evt if $evt;
1909     $evt = _reset_hold($self, $reqr, $hold);
1910     return $evt if $evt;
1911     return 1;
1912 }
1913
1914
1915 __PACKAGE__->register_method(
1916     method   => 'reset_hold_batch',
1917     api_name => 'open-ils.circ.hold.reset.batch'
1918 );
1919
1920 sub reset_hold_batch {
1921     my($self, $conn, $auth, $hold_ids) = @_;
1922
1923     my $e = new_editor(authtoken => $auth);
1924     return $e->event unless $e->checkauth;
1925
1926     for my $hold_id ($hold_ids) {
1927
1928         my $hold = $e->retrieve_action_hold_request(
1929             [$hold_id, {flesh => 1, flesh_fields => {ahr => ['usr']}}])
1930             or return $e->event;
1931
1932         next unless $e->allowed('UPDATE_HOLD', $hold->usr->home_ou);
1933         _reset_hold($self, $e->requestor, $hold);
1934     }
1935
1936     return 1;
1937 }
1938
1939
1940 sub _reset_hold {
1941     my ($self, $reqr, $hold) = @_;
1942
1943     my $e = new_editor(xact =>1, requestor => $reqr);
1944
1945     $logger->info("reseting hold ".$hold->id);
1946
1947     my $hid = $hold->id;
1948
1949     if( $hold->capture_time and $hold->current_copy ) {
1950
1951         my $copy = $e->retrieve_asset_copy($hold->current_copy)
1952             or return $e->die_event;
1953
1954         if( $copy->status == OILS_COPY_STATUS_ON_HOLDS_SHELF ) {
1955             $logger->info("setting copy to status 'reshelving' on hold retarget");
1956             $copy->status(OILS_COPY_STATUS_RESHELVING);
1957             $copy->editor($e->requestor->id);
1958             $copy->edit_date('now');
1959             $e->update_asset_copy($copy) or return $e->die_event;
1960
1961         } elsif( $copy->status == OILS_COPY_STATUS_IN_TRANSIT ) {
1962
1963             # We don't want the copy to remain "in transit"
1964             $copy->status(OILS_COPY_STATUS_RESHELVING);
1965             $logger->warn("! reseting hold [$hid] that is in transit");
1966             my $transid = $e->search_action_hold_transit_copy({hold=>$hold->id},{idlist=>1})->[0];
1967
1968             if( $transid ) {
1969                 my $trans = $e->retrieve_action_transit_copy($transid);
1970                 if( $trans ) {
1971                     $logger->info("Aborting transit [$transid] on hold [$hid] reset...");
1972                     my $evt = OpenILS::Application::Circ::Transit::__abort_transit($e, $trans, $copy, 1, 1);
1973                     $logger->info("Transit abort completed with result $evt");
1974                     unless ("$evt" eq 1) {
1975                         $e->rollback;
1976                         return $evt;
1977                     }
1978                 }
1979             }
1980         }
1981     }
1982
1983     $hold->clear_capture_time;
1984     $hold->clear_current_copy;
1985     $hold->clear_shelf_time;
1986     $hold->clear_shelf_expire_time;
1987     $hold->clear_current_shelf_lib;
1988
1989     $e->update_action_hold_request($hold) or return $e->die_event;
1990     $e->commit;
1991
1992     $U->storagereq(
1993         'open-ils.storage.action.hold_request.copy_targeter', undef, $hold->id );
1994
1995     return undef;
1996 }
1997
1998
1999 __PACKAGE__->register_method(
2000     method    => 'fetch_open_title_holds',
2001     api_name  => 'open-ils.circ.open_holds.retrieve',
2002     signature => q/
2003         Returns a list ids of un-fulfilled holds for a given title id
2004         @param authtoken The login session key
2005         @param id the id of the item whose holds we want to retrieve
2006         @param type The hold type - M, T, I, V, C, F, R
2007     /
2008 );
2009
2010 sub fetch_open_title_holds {
2011     my( $self, $conn, $auth, $id, $type, $org ) = @_;
2012     my $e = new_editor( authtoken => $auth );
2013     return $e->event unless $e->checkauth;
2014
2015     $type ||= "T";
2016     $org  ||= $e->requestor->ws_ou;
2017
2018 #    return $e->search_action_hold_request(
2019 #        { target => $id, hold_type => $type, fulfillment_time => undef }, {idlist=>1});
2020
2021     # XXX make me return IDs in the future ^--
2022     my $holds = $e->search_action_hold_request(
2023         {
2024             target           => $id,
2025             cancel_time      => undef,
2026             hold_type        => $type,
2027             fulfillment_time => undef
2028         }
2029     );
2030
2031     flesh_hold_transits($holds);
2032     return $holds;
2033 }
2034
2035
2036 sub flesh_hold_transits {
2037     my $holds = shift;
2038     for my $hold ( @$holds ) {
2039         $hold->transit(
2040             $apputils->simplereq(
2041                 'open-ils.cstore',
2042                 "open-ils.cstore.direct.action.hold_transit_copy.search.atomic",
2043                 { hold => $hold->id },
2044                 { order_by => { ahtc => 'id desc' }, limit => 1 }
2045             )->[0]
2046         );
2047     }
2048 }
2049
2050 sub flesh_hold_notices {
2051     my( $holds, $e ) = @_;
2052     $e ||= new_editor();
2053
2054     for my $hold (@$holds) {
2055         my $notices = $e->search_action_hold_notification(
2056             [
2057                 { hold => $hold->id },
2058                 { order_by => { anh => 'notify_time desc' } },
2059             ],
2060             {idlist=>1}
2061         );
2062
2063         $hold->notify_count(scalar(@$notices));
2064         if( @$notices ) {
2065             my $n = $e->retrieve_action_hold_notification($$notices[0])
2066                 or return $e->event;
2067             $hold->notify_time($n->notify_time);
2068         }
2069     }
2070 }
2071
2072
2073 __PACKAGE__->register_method(
2074     method    => 'fetch_captured_holds',
2075     api_name  => 'open-ils.circ.captured_holds.on_shelf.retrieve',
2076     stream    => 1,
2077     authoritative => 1,
2078     signature => q/
2079         Returns a list of un-fulfilled holds (on the Holds Shelf) for a given title id
2080         @param authtoken The login session key
2081         @param org The org id of the location in question
2082         @param match_copy A specific copy to limit to
2083     /
2084 );
2085
2086 __PACKAGE__->register_method(
2087     method    => 'fetch_captured_holds',
2088     api_name  => 'open-ils.circ.captured_holds.id_list.on_shelf.retrieve',
2089     stream    => 1,
2090     authoritative => 1,
2091     signature => q/
2092         Returns list ids of un-fulfilled holds (on the Holds Shelf) for a given title id
2093         @param authtoken The login session key
2094         @param org The org id of the location in question
2095         @param match_copy A specific copy to limit to
2096     /
2097 );
2098
2099 __PACKAGE__->register_method(
2100     method    => 'fetch_captured_holds',
2101     api_name  => 'open-ils.circ.captured_holds.id_list.expired_on_shelf.retrieve',
2102     stream    => 1,
2103     authoritative => 1,
2104     signature => q/
2105         Returns list ids of shelf-expired un-fulfilled holds for a given title id
2106         @param authtoken The login session key
2107         @param org The org id of the location in question
2108         @param match_copy A specific copy to limit to
2109     /
2110 );
2111
2112 __PACKAGE__->register_method(
2113     method    => 'fetch_captured_holds',
2114     api_name  => 
2115       'open-ils.circ.captured_holds.expired_on_shelf_or_wrong_shelf.retrieve',
2116     stream    => 1,
2117     authoritative => 1,
2118     signature => q/
2119         Returns list of shelf-expired un-fulfilled holds OR wrong shelf holds
2120         for a given shelf lib
2121     /
2122 );
2123
2124 __PACKAGE__->register_method(
2125     method    => 'fetch_captured_holds',
2126     api_name  => 
2127       'open-ils.circ.captured_holds.id_list.expired_on_shelf_or_wrong_shelf.retrieve',
2128     stream    => 1,
2129     authoritative => 1,
2130     signature => q/
2131         Returns list of shelf-expired un-fulfilled holds OR wrong shelf holds
2132         for a given shelf lib
2133     /
2134 );
2135
2136
2137 sub fetch_captured_holds {
2138     my( $self, $conn, $auth, $org, $match_copy ) = @_;
2139
2140     my $e = new_editor(authtoken => $auth);
2141     return $e->die_event unless $e->checkauth;
2142     return $e->die_event unless $e->allowed('VIEW_HOLD'); # XXX rely on editor perm
2143
2144     $org ||= $e->requestor->ws_ou;
2145
2146     my $current_copy = { '!=' => undef };
2147     $current_copy = { '=' => $match_copy } if $match_copy;
2148
2149     my $query = {
2150         select => { alhr => ['id'] },
2151         from   => {
2152             alhr => {
2153                 acp => {
2154                     field => 'id',
2155                     fkey  => 'current_copy'
2156                 },
2157             }
2158         },
2159         where => {
2160             '+acp' => { status => OILS_COPY_STATUS_ON_HOLDS_SHELF, deleted => 'f' },
2161             '+alhr' => {
2162                 capture_time      => { "!=" => undef },
2163                 current_copy      => $current_copy,
2164                 fulfillment_time  => undef,
2165                 current_shelf_lib => $org
2166             }
2167         }
2168     };
2169     if($self->api_name =~ /expired/) {
2170         $query->{'where'}->{'+alhr'}->{'-or'} = {
2171                 shelf_expire_time => { '<' => 'today'},
2172                 cancel_time => { '!=' => undef },
2173         };
2174     }
2175     my $hold_ids = $e->json_query( $query );
2176
2177     if ($self->api_name =~ /wrong_shelf/) {
2178         # fetch holds whose current_shelf_lib is $org, but whose pickup 
2179         # lib is some other org unit.  Ignore already-retrieved holds.
2180         my $wrong_shelf =
2181             pickup_lib_changed_on_shelf_holds(
2182                 $e, $org, [map {$_->{id}} @$hold_ids]);
2183         # match the layout of other items in $hold_ids
2184         push (@$hold_ids, {id => $_}) for @$wrong_shelf;
2185     }
2186
2187
2188     for my $hold_id (@$hold_ids) {
2189         if($self->api_name =~ /id_list/) {
2190             $conn->respond($hold_id->{id});
2191             next;
2192         } else {
2193             $conn->respond(
2194                 $e->retrieve_action_hold_request([
2195                     $hold_id->{id},
2196                     {
2197                         flesh => 1,
2198                         flesh_fields => {ahr => ['notifications', 'transit', 'notes']},
2199                         order_by => {anh => 'notify_time desc'}
2200                     }
2201                 ])
2202             );
2203         }
2204     }
2205
2206     return undef;
2207 }
2208
2209 __PACKAGE__->register_method(
2210     method    => "print_expired_holds_stream",
2211     api_name  => "open-ils.circ.captured_holds.expired.print.stream",
2212     stream    => 1
2213 );
2214
2215 sub print_expired_holds_stream {
2216     my ($self, $client, $auth, $params) = @_;
2217
2218     # No need to check specific permissions: we're going to call another method
2219     # that will do that.
2220     my $e = new_editor("authtoken" => $auth);
2221     return $e->die_event unless $e->checkauth;
2222
2223     delete($$params{org_id}) unless (int($$params{org_id}));
2224     delete($$params{limit}) unless (int($$params{limit}));
2225     delete($$params{offset}) unless (int($$params{offset}));
2226     delete($$params{chunk_size}) unless (int($$params{chunk_size}));
2227     delete($$params{chunk_size}) if  ($$params{chunk_size} && $$params{chunk_size} > 50); # keep the size reasonable
2228     $$params{chunk_size} ||= 10;
2229     $client->max_chunk_size($$params{chunk_size});
2230
2231     $$params{org_id} = (defined $$params{org_id}) ? $$params{org_id}: $e->requestor->ws_ou;
2232
2233     my @hold_ids = $self->method_lookup(
2234         "open-ils.circ.captured_holds.id_list.expired_on_shelf.retrieve"
2235     )->run($auth, $params->{"org_id"});
2236
2237     if (!@hold_ids) {
2238         $e->disconnect;
2239         return;
2240     } elsif (defined $U->event_code($hold_ids[0])) {
2241         $e->disconnect;
2242         return $hold_ids[0];
2243     }
2244
2245     $logger->info("about to stream back up to " . scalar(@hold_ids) . " expired holds");
2246
2247     while (@hold_ids) {
2248         my @hid_chunk = splice @hold_ids, 0, $params->{"chunk_size"};
2249
2250         my $result_chunk = $e->json_query({
2251             "select" => {
2252                 "acp" => ["barcode"],
2253                 "au" => [qw/
2254                     first_given_name second_given_name family_name alias
2255                 /],
2256                 "acn" => ["label"],
2257                 "bre" => ["marc"],
2258                 "acpl" => ["name"]
2259             },
2260             "from" => {
2261                 "ahr" => {
2262                     "acp" => {
2263                         "field" => "id", "fkey" => "current_copy",
2264                         "join" => {
2265                             "acn" => {
2266                                 "field" => "id", "fkey" => "call_number",
2267                                 "join" => {
2268                                     "bre" => {
2269                                         "field" => "id", "fkey" => "record"
2270                                     }
2271                                 }
2272                             },
2273                             "acpl" => {"field" => "id", "fkey" => "location"}
2274                         }
2275                     },
2276                     "au" => {"field" => "id", "fkey" => "usr"}
2277                 }
2278             },
2279             "where" => {"+ahr" => {"id" => \@hid_chunk}}
2280         }) or return $e->die_event;
2281         $client->respond($result_chunk);
2282     }
2283
2284     $e->disconnect;
2285     undef;
2286 }
2287
2288 __PACKAGE__->register_method(
2289     method    => "check_title_hold_batch",
2290     api_name  => "open-ils.circ.title_hold.is_possible.batch",
2291     stream    => 1,
2292     signature => {
2293         desc  => '@see open-ils.circ.title_hold.is_possible.batch',
2294         params => [
2295             { desc => 'Authentication token',     type => 'string'},
2296             { desc => 'Array of Hash of named parameters', type => 'array'},
2297         ],
2298         return => {
2299             desc => 'Array of response objects',
2300             type => 'array'
2301         }
2302     }
2303 );
2304
2305 sub check_title_hold_batch {
2306     my($self, $client, $authtoken, $param_list, $oargs) = @_;
2307     foreach (@$param_list) {
2308         my ($res) = $self->method_lookup('open-ils.circ.title_hold.is_possible')->run($authtoken, $_, $oargs);
2309         $client->respond($res);
2310     }
2311     return undef;
2312 }
2313
2314
2315 __PACKAGE__->register_method(
2316     method    => "check_title_hold",
2317     api_name  => "open-ils.circ.title_hold.is_possible",
2318     signature => {
2319         desc  => 'Determines if a hold were to be placed by a given user, ' .
2320              'whether or not said hold would have any potential copies to fulfill it.' .
2321              'The named paramaters of the second argument include: ' .
2322              'patronid, titleid, volume_id, copy_id, mrid, depth, pickup_lib, hold_type, selection_ou. ' .
2323              'See perldoc ' . __PACKAGE__ . ' for more info on these fields.' ,
2324         params => [
2325             { desc => 'Authentication token',     type => 'string'},
2326             { desc => 'Hash of named parameters', type => 'object'},
2327         ],
2328         return => {
2329             desc => 'List of new message IDs (empty if none)',
2330             type => 'array'
2331         }
2332     }
2333 );
2334
2335 =head3 check_title_hold (token, hash)
2336
2337 The named fields in the hash are:
2338
2339  patronid     - ID of the hold recipient  (required)
2340  depth        - hold range depth          (default 0)
2341  pickup_lib   - destination for hold, fallback value for selection_ou
2342  selection_ou - ID of org_unit establishing hard and soft hold boundary settings
2343  issuanceid   - ID of the issuance to be held, required for Issuance level hold
2344  partid       - ID of the monograph part to be held, required for monograph part level hold
2345  titleid      - ID (BRN) of the title to be held, required for Title level hold
2346  volume_id    - required for Volume level hold
2347  copy_id      - required for Copy level hold
2348  mrid         - required for Meta-record level hold
2349  hold_type    - T, C (or R or F), I, V or M for Title, Copy, Issuance, Volume or Meta-record  (default "T")
2350
2351 All key/value pairs are passed on to do_possibility_checks.
2352
2353 =cut
2354
2355 # FIXME: better params checking.  what other params are required, if any?
2356 # FIXME: 3 copies of values confusing: $x, $params->{x} and $params{x}
2357 # FIXME: for example, $depth gets a default value, but then $$params{depth} is still
2358 # used in conditionals, where it may be undefined, causing a warning.
2359 # FIXME: specify proper usage/interaction of selection_ou and pickup_lib
2360
2361 sub check_title_hold {
2362     my( $self, $client, $authtoken, $params ) = @_;
2363     my $e = new_editor(authtoken=>$authtoken);
2364     return $e->event unless $e->checkauth;
2365
2366     my %params       = %$params;
2367     my $depth        = $params{depth}        || 0;
2368     my $selection_ou = $params{selection_ou} || $params{pickup_lib};
2369     my $oargs        = $params{oargs}        || {};
2370
2371     if($oargs->{events}) {
2372         @{$oargs->{events}} = grep { $e->allowed($_ . '.override', $e->requestor->ws_ou); } @{$oargs->{events}};
2373     }
2374
2375
2376     my $patron = $e->retrieve_actor_user($params{patronid})
2377         or return $e->event;
2378
2379     if( $e->requestor->id ne $patron->id ) {
2380         return $e->event unless
2381             $e->allowed('VIEW_HOLD_PERMIT', $patron->home_ou);
2382     }
2383
2384     return OpenILS::Event->new('PATRON_BARRED') if $U->is_true($patron->barred);
2385
2386     my $request_lib = $e->retrieve_actor_org_unit($e->requestor->ws_ou)
2387         or return $e->event;
2388
2389     my $soft_boundary = $U->ou_ancestor_setting_value($selection_ou, OILS_SETTING_HOLD_SOFT_BOUNDARY);
2390     my $hard_boundary = $U->ou_ancestor_setting_value($selection_ou, OILS_SETTING_HOLD_HARD_BOUNDARY);
2391
2392     my @status = ();
2393     my $return_depth = $hard_boundary; # default depth to return on success
2394     if(defined $soft_boundary and $depth < $soft_boundary) {
2395         # work up the tree and as soon as we find a potential copy, use that depth
2396         # also, make sure we don't go past the hard boundary if it exists
2397
2398         # our min boundary is the greater of user-specified boundary or hard boundary
2399         my $min_depth = (defined $hard_boundary and $hard_boundary > $depth) ?
2400             $hard_boundary : $depth;
2401
2402         my $depth = $soft_boundary;
2403         while($depth >= $min_depth) {
2404             $logger->info("performing hold possibility check with soft boundary $depth");
2405             @status = do_possibility_checks($e, $patron, $request_lib, $depth, %params);
2406             if ($status[0]) {
2407                 $return_depth = $depth;
2408                 last;
2409             }
2410             $depth--;
2411         }
2412     } elsif(defined $hard_boundary and $depth < $hard_boundary) {
2413         # there is no soft boundary, enforce the hard boundary if it exists
2414         $logger->info("performing hold possibility check with hard boundary $hard_boundary");
2415         @status = do_possibility_checks($e, $patron, $request_lib, $hard_boundary, %params);
2416     } else {
2417         # no boundaries defined, fall back to user specifed boundary or no boundary
2418         $logger->info("performing hold possibility check with no boundary");
2419         @status = do_possibility_checks($e, $patron, $request_lib, $params{depth}, %params);
2420     }
2421
2422     my $place_unfillable = 0;
2423     $place_unfillable = 1 if $e->allowed('PLACE_UNFILLABLE_HOLD', $e->requestor->ws_ou);
2424
2425     if ($status[0]) {
2426         return {
2427             "success" => 1,
2428             "depth" => $return_depth,
2429             "local_avail" => $status[1]
2430         };
2431     } elsif ($status[2]) {
2432         my $n = scalar @{$status[2]};
2433         return {"success" => 0, "last_event" => $status[2]->[$n - 1], "age_protected_copy" => $status[3], "place_unfillable" => $place_unfillable};
2434     } else {
2435         return {"success" => 0, "age_protected_copy" => $status[3], "place_unfillable" => $place_unfillable};
2436     }
2437 }
2438
2439
2440
2441 sub do_possibility_checks {
2442     my($e, $patron, $request_lib, $depth, %params) = @_;
2443
2444     my $issuanceid   = $params{issuanceid}      || "";
2445     my $partid       = $params{partid}      || "";
2446     my $titleid      = $params{titleid}      || "";
2447     my $volid        = $params{volume_id};
2448     my $copyid       = $params{copy_id};
2449     my $mrid         = $params{mrid}         || "";
2450     my $pickup_lib   = $params{pickup_lib};
2451     my $hold_type    = $params{hold_type}    || 'T';
2452     my $selection_ou = $params{selection_ou} || $pickup_lib;
2453     my $holdable_formats = $params{holdable_formats};
2454     my $oargs        = $params{oargs}        || {};
2455
2456
2457     my $copy;
2458     my $volume;
2459     my $title;
2460
2461     if( $hold_type eq OILS_HOLD_TYPE_FORCE || $hold_type eq OILS_HOLD_TYPE_RECALL || $hold_type eq OILS_HOLD_TYPE_COPY ) {
2462
2463         return $e->event unless $copy   = $e->retrieve_asset_copy($copyid);
2464         return $e->event unless $volume = $e->retrieve_asset_call_number($copy->call_number);
2465         return $e->event unless $title  = $e->retrieve_biblio_record_entry($volume->record);
2466
2467         return (1, 1, []) if( $hold_type eq OILS_HOLD_TYPE_RECALL || $hold_type eq OILS_HOLD_TYPE_FORCE);
2468         return verify_copy_for_hold(
2469             $patron, $e->requestor, $title, $copy, $pickup_lib, $request_lib, $oargs
2470         );
2471
2472     } elsif( $hold_type eq OILS_HOLD_TYPE_VOLUME ) {
2473
2474         return $e->event unless $volume = $e->retrieve_asset_call_number($volid);
2475         return $e->event unless $title  = $e->retrieve_biblio_record_entry($volume->record);
2476
2477         return _check_volume_hold_is_possible(
2478             $volume, $title, $depth, $request_lib, $patron, $e->requestor, $pickup_lib, $selection_ou, $oargs
2479         );
2480
2481     } elsif( $hold_type eq OILS_HOLD_TYPE_TITLE ) {
2482
2483         return _check_title_hold_is_possible(
2484             $titleid, $depth, $request_lib, $patron, $e->requestor, $pickup_lib, $selection_ou, undef, $oargs
2485         );
2486
2487     } elsif( $hold_type eq OILS_HOLD_TYPE_ISSUANCE ) {
2488
2489         return _check_issuance_hold_is_possible(
2490             $issuanceid, $depth, $request_lib, $patron, $e->requestor, $pickup_lib, $selection_ou, $oargs
2491         );
2492
2493     } elsif( $hold_type eq OILS_HOLD_TYPE_MONOPART ) {
2494
2495         return _check_monopart_hold_is_possible(
2496             $partid, $depth, $request_lib, $patron, $e->requestor, $pickup_lib, $selection_ou, $oargs
2497         );
2498
2499     } elsif( $hold_type eq OILS_HOLD_TYPE_METARECORD ) {
2500
2501         # pasing undef as the depth to filtered_records causes the depth
2502         # of the selection_ou to be used, which is not what we want here.
2503         $depth ||= 0;
2504
2505         my ($recs) = __PACKAGE__->method_lookup('open-ils.circ.holds.metarecord.filtered_records')->run($mrid, $holdable_formats, $selection_ou, $depth);
2506         my @status = ();
2507         for my $rec (@$recs) {
2508             @status = _check_title_hold_is_possible(
2509                 $rec, $depth, $request_lib, $patron, $e->requestor, $pickup_lib, $selection_ou, $holdable_formats, $oargs
2510             );
2511             last if $status[0];
2512         }
2513         return @status;
2514     }
2515 #   else { Unrecognized hold_type ! }   # FIXME: return error? or 0?
2516 }
2517
2518 sub MR_filter_records {
2519     my $self = shift;
2520     my $client = shift;
2521     my $m = shift;
2522     my $f = shift;
2523     my $o = shift;
2524     my $d = shift;
2525     my $opac_visible = shift;
2526     
2527     my $org_at_depth = defined($d) ? $U->org_unit_ancestor_at_depth($o, $d) : $o;
2528     return $U->storagereq(
2529         'open-ils.storage.metarecord.filtered_records.atomic', 
2530         $m, $f, $org_at_depth, $opac_visible
2531     );
2532 }
2533 __PACKAGE__->register_method(
2534     method   => 'MR_filter_records',
2535     api_name => 'open-ils.circ.holds.metarecord.filtered_records',
2536 );
2537
2538
2539 my %prox_cache;
2540 sub create_ranged_org_filter {
2541     my($e, $selection_ou, $depth) = @_;
2542
2543     # find the orgs from which this hold may be fulfilled,
2544     # based on the selection_ou and depth
2545
2546     my $top_org = $e->search_actor_org_unit([
2547         {parent_ou => undef},
2548         {flesh=>1, flesh_fields=>{aou=>['ou_type']}}])->[0];
2549     my %org_filter;
2550
2551     return () if $depth == $top_org->ou_type->depth;
2552
2553     my $org_list = $U->storagereq('open-ils.storage.actor.org_unit.descendants.atomic', $selection_ou, $depth);
2554     %org_filter = (circ_lib => []);
2555     push(@{$org_filter{circ_lib}}, $_->id) for @$org_list;
2556
2557     $logger->info("hold org filter at depth $depth and selection_ou ".
2558         "$selection_ou created list of @{$org_filter{circ_lib}}");
2559
2560     return %org_filter;
2561 }
2562
2563
2564 sub _check_title_hold_is_possible {
2565     my( $titleid, $depth, $request_lib, $patron, $requestor, $pickup_lib, $selection_ou, $holdable_formats, $oargs ) = @_;
2566     # $holdable_formats is now unused. We pre-filter the MR's records.
2567
2568     my $e = new_editor();
2569     my %org_filter = create_ranged_org_filter($e, $selection_ou, $depth);
2570
2571     # this monster will grab the id and circ_lib of all of the "holdable" copies for the given record
2572     my $copies = $e->json_query(
2573         {
2574             select => { acp => ['id', 'circ_lib'] },
2575               from => {
2576                 acp => {
2577                     acn => {
2578                         field  => 'id',
2579                         fkey   => 'call_number',
2580                         filter => { record => $titleid }
2581                     },
2582                     acpl => {
2583                                 field => 'id',
2584                                 filter => { holdable => 't', deleted => 'f' },
2585                                 fkey => 'location'
2586                             },
2587                     ccs  => { field => 'id', filter => { holdable => 't'}, fkey => 'status'   },
2588                     acpm => { field => 'target_copy', type => 'left' } # ignore part-linked copies
2589                 }
2590             },
2591             where => {
2592                 '+acp' => { circulate => 't', deleted => 'f', holdable => 't', %org_filter },
2593                 '+acpm' => { target_copy => undef } # ignore part-linked copies
2594             }
2595         }
2596     );
2597
2598     $logger->info("title possible found ".scalar(@$copies)." potential copies");
2599     return (
2600         0, 0, [
2601             new OpenILS::Event(
2602                 "HIGH_LEVEL_HOLD_HAS_NO_COPIES",
2603                 "payload" => {"fail_part" => "no_ultimate_items"}
2604             )
2605         ]
2606     ) unless @$copies;
2607
2608     # -----------------------------------------------------------------------
2609     # sort the copies into buckets based on their circ_lib proximity to
2610     # the patron's home_ou.
2611     # -----------------------------------------------------------------------
2612
2613     my $home_org = $patron->home_ou;
2614     my $req_org = $request_lib->id;
2615
2616     $logger->info("prox cache $home_org " . $prox_cache{$home_org});
2617
2618     $prox_cache{$home_org} =
2619         $e->search_actor_org_unit_proximity({from_org => $home_org})
2620         unless $prox_cache{$home_org};
2621     my $home_prox = $prox_cache{$home_org};
2622
2623     my %buckets;
2624     my %hash = map { ($_->to_org => $_->prox) } @$home_prox;
2625     push( @{$buckets{ $hash{$_->{circ_lib}} } }, $_->{id} ) for @$copies;
2626
2627     my @keys = sort { $a <=> $b } keys %buckets;
2628
2629
2630     if( $home_org ne $req_org ) {
2631       # -----------------------------------------------------------------------
2632       # shove the copies close to the request_lib into the primary buckets
2633       # directly before the farthest away copies.  That way, they are not
2634       # given priority, but they are checked before the farthest copies.
2635       # -----------------------------------------------------------------------
2636         $prox_cache{$req_org} =
2637             $e->search_actor_org_unit_proximity({from_org => $req_org})
2638             unless $prox_cache{$req_org};
2639         my $req_prox = $prox_cache{$req_org};
2640
2641         my %buckets2;
2642         my %hash2 = map { ($_->to_org => $_->prox) } @$req_prox;
2643         push( @{$buckets2{ $hash2{$_->{circ_lib}} } }, $_->{id} ) for @$copies;
2644
2645         my $highest_key = $keys[@keys - 1];  # the farthest prox in the exising buckets
2646         my $new_key = $highest_key - 0.5; # right before the farthest prox
2647         my @keys2   = sort { $a <=> $b } keys %buckets2;
2648         for my $key (@keys2) {
2649             last if $key >= $highest_key;
2650             push( @{$buckets{$new_key}}, $_ ) for @{$buckets2{$key}};
2651         }
2652     }
2653
2654     @keys = sort { $a <=> $b } keys %buckets;
2655
2656     my $title;
2657     my %seen;
2658     my @status;
2659     my $age_protect_only = 0;
2660     OUTER: for my $key (@keys) {
2661       my @cps = @{$buckets{$key}};
2662
2663       $logger->info("looking at " . scalar(@{$buckets{$key}}). " copies in proximity bucket $key");
2664
2665       for my $copyid (@cps) {
2666
2667          next if $seen{$copyid};
2668          $seen{$copyid} = 1; # there could be dupes given the merged buckets
2669          my $copy = $e->retrieve_asset_copy($copyid);
2670          $logger->debug("looking at bucket_key=$key, copy $copyid : circ_lib = " . $copy->circ_lib);
2671
2672          unless($title) { # grab the title if we don't already have it
2673             my $vol = $e->retrieve_asset_call_number(
2674                [ $copy->call_number, { flesh => 1, flesh_fields => { bre => ['fixed_fields'], acn => ['record'] } } ] );
2675             $title = $vol->record;
2676          }
2677
2678          @status = verify_copy_for_hold(
2679             $patron, $requestor, $title, $copy, $pickup_lib, $request_lib, $oargs);
2680
2681          $age_protect_only ||= $status[3];
2682          last OUTER if $status[0];
2683       }
2684     }
2685
2686     $status[3] = $age_protect_only;
2687     return @status;
2688 }
2689
2690 sub _check_issuance_hold_is_possible {
2691     my( $issuanceid, $depth, $request_lib, $patron, $requestor, $pickup_lib, $selection_ou, $oargs ) = @_;
2692
2693     my $e = new_editor();
2694     my %org_filter = create_ranged_org_filter($e, $selection_ou, $depth);
2695
2696     # this monster will grab the id and circ_lib of all of the "holdable" copies for the given record
2697     my $copies = $e->json_query(
2698         {
2699             select => { acp => ['id', 'circ_lib'] },
2700               from => {
2701                 acp => {
2702                     sitem => {
2703                         field  => 'unit',
2704                         fkey   => 'id',
2705                         filter => { issuance => $issuanceid }
2706                     },
2707                     acpl => {
2708                         field => 'id',
2709                         filter => { holdable => 't', deleted => 'f' },
2710                         fkey => 'location'
2711                     },
2712                     ccs  => { field => 'id', filter => { holdable => 't'}, fkey => 'status'   }
2713                 }
2714             },
2715             where => {
2716                 '+acp' => { circulate => 't', deleted => 'f', holdable => 't', %org_filter }
2717             },
2718             distinct => 1
2719         }
2720     );
2721
2722     $logger->info("issuance possible found ".scalar(@$copies)." potential copies");
2723
2724     my $empty_ok;
2725     if (!@$copies) {
2726         $empty_ok = $e->retrieve_config_global_flag('circ.holds.empty_issuance_ok');
2727         $empty_ok = ($empty_ok and $U->is_true($empty_ok->enabled));
2728
2729         return (
2730             0, 0, [
2731                 new OpenILS::Event(
2732                     "HIGH_LEVEL_HOLD_HAS_NO_COPIES",
2733                     "payload" => {"fail_part" => "no_ultimate_items"}
2734                 )
2735             ]
2736         ) unless $empty_ok;
2737
2738         return (1, 0);
2739     }
2740
2741     # -----------------------------------------------------------------------
2742     # sort the copies into buckets based on their circ_lib proximity to
2743     # the patron's home_ou.
2744     # -----------------------------------------------------------------------
2745
2746     my $home_org = $patron->home_ou;
2747     my $req_org = $request_lib->id;
2748
2749     $logger->info("prox cache $home_org " . $prox_cache{$home_org});
2750
2751     $prox_cache{$home_org} =
2752         $e->search_actor_org_unit_proximity({from_org => $home_org})
2753         unless $prox_cache{$home_org};
2754     my $home_prox = $prox_cache{$home_org};
2755
2756     my %buckets;
2757     my %hash = map { ($_->to_org => $_->prox) } @$home_prox;
2758     push( @{$buckets{ $hash{$_->{circ_lib}} } }, $_->{id} ) for @$copies;
2759
2760     my @keys = sort { $a <=> $b } keys %buckets;
2761
2762
2763     if( $home_org ne $req_org ) {
2764       # -----------------------------------------------------------------------
2765       # shove the copies close to the request_lib into the primary buckets
2766       # directly before the farthest away copies.  That way, they are not
2767       # given priority, but they are checked before the farthest copies.
2768       # -----------------------------------------------------------------------
2769         $prox_cache{$req_org} =
2770             $e->search_actor_org_unit_proximity({from_org => $req_org})
2771             unless $prox_cache{$req_org};
2772         my $req_prox = $prox_cache{$req_org};
2773
2774         my %buckets2;
2775         my %hash2 = map { ($_->to_org => $_->prox) } @$req_prox;
2776         push( @{$buckets2{ $hash2{$_->{circ_lib}} } }, $_->{id} ) for @$copies;
2777
2778         my $highest_key = $keys[@keys - 1];  # the farthest prox in the exising buckets
2779         my $new_key = $highest_key - 0.5; # right before the farthest prox
2780         my @keys2   = sort { $a <=> $b } keys %buckets2;
2781         for my $key (@keys2) {
2782             last if $key >= $highest_key;
2783             push( @{$buckets{$new_key}}, $_ ) for @{$buckets2{$key}};
2784         }
2785     }
2786
2787     @keys = sort { $a <=> $b } keys %buckets;
2788
2789     my $title;
2790     my %seen;
2791     my @status;
2792     my $age_protect_only = 0;
2793     OUTER: for my $key (@keys) {
2794       my @cps = @{$buckets{$key}};
2795
2796       $logger->info("looking at " . scalar(@{$buckets{$key}}). " copies in proximity bucket $key");
2797
2798       for my $copyid (@cps) {
2799
2800          next if $seen{$copyid};
2801          $seen{$copyid} = 1; # there could be dupes given the merged buckets
2802          my $copy = $e->retrieve_asset_copy($copyid);
2803          $logger->debug("looking at bucket_key=$key, copy $copyid : circ_lib = " . $copy->circ_lib);
2804
2805          unless($title) { # grab the title if we don't already have it
2806             my $vol = $e->retrieve_asset_call_number(
2807                [ $copy->call_number, { flesh => 1, flesh_fields => { bre => ['fixed_fields'], acn => ['record'] } } ] );
2808             $title = $vol->record;
2809          }
2810
2811          @status = verify_copy_for_hold(
2812             $patron, $requestor, $title, $copy, $pickup_lib, $request_lib, $oargs);
2813
2814          $age_protect_only ||= $status[3];
2815          last OUTER if $status[0];
2816       }
2817     }
2818
2819     if (!$status[0]) {
2820         if (!defined($empty_ok)) {
2821             $empty_ok = $e->retrieve_config_global_flag('circ.holds.empty_issuance_ok');
2822             $empty_ok = ($empty_ok and $U->is_true($empty_ok->enabled));
2823         }
2824
2825         return (1,0) if ($empty_ok);
2826     }
2827     $status[3] = $age_protect_only;
2828     return @status;
2829 }
2830
2831 sub _check_monopart_hold_is_possible {
2832     my( $partid, $depth, $request_lib, $patron, $requestor, $pickup_lib, $selection_ou, $oargs ) = @_;
2833
2834     my $e = new_editor();
2835     my %org_filter = create_ranged_org_filter($e, $selection_ou, $depth);
2836
2837     # this monster will grab the id and circ_lib of all of the "holdable" copies for the given record
2838     my $copies = $e->json_query(
2839         {
2840             select => { acp => ['id', 'circ_lib'] },
2841               from => {
2842                 acp => {
2843                     acpm => {
2844                         field  => 'target_copy',
2845                         fkey   => 'id',
2846                         filter => { part => $partid }
2847                     },
2848                     acpl => {
2849                         field => 'id',
2850                         filter => { holdable => 't', deleted => 'f' },
2851                         fkey => 'location'
2852                     },
2853                     ccs  => { field => 'id', filter => { holdable => 't'}, fkey => 'status'   }
2854                 }
2855             },
2856             where => {
2857                 '+acp' => { circulate => 't', deleted => 'f', holdable => 't', %org_filter }
2858             },
2859             distinct => 1
2860         }
2861     );
2862
2863     $logger->info("monopart possible found ".scalar(@$copies)." potential copies");
2864
2865     my $empty_ok;
2866     if (!@$copies) {
2867         $empty_ok = $e->retrieve_config_global_flag('circ.holds.empty_part_ok');
2868         $empty_ok = ($empty_ok and $U->is_true($empty_ok->enabled));
2869
2870         return (
2871             0, 0, [
2872                 new OpenILS::Event(
2873                     "HIGH_LEVEL_HOLD_HAS_NO_COPIES",
2874                     "payload" => {"fail_part" => "no_ultimate_items"}
2875                 )
2876             ]
2877         ) unless $empty_ok;
2878
2879         return (1, 0);
2880     }
2881
2882     # -----------------------------------------------------------------------
2883     # sort the copies into buckets based on their circ_lib proximity to
2884     # the patron's home_ou.
2885     # -----------------------------------------------------------------------
2886
2887     my $home_org = $patron->home_ou;
2888     my $req_org = $request_lib->id;
2889
2890     $logger->info("prox cache $home_org " . $prox_cache{$home_org});
2891
2892     $prox_cache{$home_org} =
2893         $e->search_actor_org_unit_proximity({from_org => $home_org})
2894         unless $prox_cache{$home_org};
2895     my $home_prox = $prox_cache{$home_org};
2896
2897     my %buckets;
2898     my %hash = map { ($_->to_org => $_->prox) } @$home_prox;
2899     push( @{$buckets{ $hash{$_->{circ_lib}} } }, $_->{id} ) for @$copies;
2900
2901     my @keys = sort { $a <=> $b } keys %buckets;
2902
2903
2904     if( $home_org ne $req_org ) {
2905       # -----------------------------------------------------------------------
2906       # shove the copies close to the request_lib into the primary buckets
2907       # directly before the farthest away copies.  That way, they are not
2908       # given priority, but they are checked before the farthest copies.
2909       # -----------------------------------------------------------------------
2910         $prox_cache{$req_org} =
2911             $e->search_actor_org_unit_proximity({from_org => $req_org})
2912             unless $prox_cache{$req_org};
2913         my $req_prox = $prox_cache{$req_org};
2914
2915         my %buckets2;
2916         my %hash2 = map { ($_->to_org => $_->prox) } @$req_prox;
2917         push( @{$buckets2{ $hash2{$_->{circ_lib}} } }, $_->{id} ) for @$copies;
2918
2919         my $highest_key = $keys[@keys - 1];  # the farthest prox in the exising buckets
2920         my $new_key = $highest_key - 0.5; # right before the farthest prox
2921         my @keys2   = sort { $a <=> $b } keys %buckets2;
2922         for my $key (@keys2) {
2923             last if $key >= $highest_key;
2924             push( @{$buckets{$new_key}}, $_ ) for @{$buckets2{$key}};
2925         }
2926     }
2927
2928     @keys = sort { $a <=> $b } keys %buckets;
2929
2930     my $title;
2931     my %seen;
2932     my @status;
2933     my $age_protect_only = 0;
2934     OUTER: for my $key (@keys) {
2935       my @cps = @{$buckets{$key}};
2936
2937       $logger->info("looking at " . scalar(@{$buckets{$key}}). " copies in proximity bucket $key");
2938
2939       for my $copyid (@cps) {
2940
2941          next if $seen{$copyid};
2942          $seen{$copyid} = 1; # there could be dupes given the merged buckets
2943          my $copy = $e->retrieve_asset_copy($copyid);
2944          $logger->debug("looking at bucket_key=$key, copy $copyid : circ_lib = " . $copy->circ_lib);
2945
2946          unless($title) { # grab the title if we don't already have it
2947             my $vol = $e->retrieve_asset_call_number(
2948                [ $copy->call_number, { flesh => 1, flesh_fields => { bre => ['fixed_fields'], acn => ['record'] } } ] );
2949             $title = $vol->record;
2950          }
2951
2952          @status = verify_copy_for_hold(
2953             $patron, $requestor, $title, $copy, $pickup_lib, $request_lib, $oargs);
2954
2955          $age_protect_only ||= $status[3];
2956          last OUTER if $status[0];
2957       }
2958     }
2959
2960     if (!$status[0]) {
2961         if (!defined($empty_ok)) {
2962             $empty_ok = $e->retrieve_config_global_flag('circ.holds.empty_part_ok');
2963             $empty_ok = ($empty_ok and $U->is_true($empty_ok->enabled));
2964         }
2965
2966         return (1,0) if ($empty_ok);
2967     }
2968     $status[3] = $age_protect_only;
2969     return @status;
2970 }
2971
2972
2973 sub _check_volume_hold_is_possible {
2974     my( $vol, $title, $depth, $request_lib, $patron, $requestor, $pickup_lib, $selection_ou, $oargs ) = @_;
2975     my %org_filter = create_ranged_org_filter(new_editor(), $selection_ou, $depth);
2976     my $copies = new_editor->search_asset_copy({call_number => $vol->id, %org_filter});
2977     $logger->info("checking possibility of volume hold for volume ".$vol->id);
2978
2979     my $filter_copies = [];
2980     for my $copy (@$copies) {
2981         # ignore part-mapped copies for regular volume level holds
2982         push(@$filter_copies, $copy) unless
2983             new_editor->search_asset_copy_part_map({target_copy => $copy->id})->[0];
2984     }
2985     $copies = $filter_copies;
2986
2987     return (
2988         0, 0, [
2989             new OpenILS::Event(
2990                 "HIGH_LEVEL_HOLD_HAS_NO_COPIES",
2991                 "payload" => {"fail_part" => "no_ultimate_items"}
2992             )
2993         ]
2994     ) unless @$copies;
2995
2996     my @status;
2997     my $age_protect_only = 0;
2998     for my $copy ( @$copies ) {
2999         @status = verify_copy_for_hold(
3000             $patron, $requestor, $title, $copy, $pickup_lib, $request_lib, $oargs );
3001         $age_protect_only ||= $status[3];
3002         last if $status[0];
3003     }
3004     $status[3] = $age_protect_only;
3005     return @status;
3006 }
3007
3008
3009
3010 sub verify_copy_for_hold {
3011     my( $patron, $requestor, $title, $copy, $pickup_lib, $request_lib, $oargs ) = @_;
3012     # $oargs should be undef unless we're overriding.
3013     $logger->info("checking possibility of copy in hold request for copy ".$copy->id);
3014     my $permitted = OpenILS::Utils::PermitHold::permit_copy_hold(
3015         {
3016             patron           => $patron,
3017             requestor        => $requestor,
3018             copy             => $copy,
3019             title            => $title,
3020             title_descriptor => $title->fixed_fields,
3021             pickup_lib       => $pickup_lib,
3022             request_lib      => $request_lib,
3023             new_hold         => 1,
3024             show_event_list  => 1
3025         }
3026     );
3027
3028     # Check for override permissions on events.
3029     if ($oargs && $permitted && scalar @$permitted) {
3030         # Remove the events from permitted that we can override.
3031         if ($oargs->{events}) {
3032             foreach my $evt (@{$oargs->{events}}) {
3033                 $permitted = [grep {$_->{textcode} ne $evt} @{$permitted}];
3034             }
3035         }
3036         # Now, we handle the override all case by checking remaining
3037         # events against override permissions.
3038         if (scalar @$permitted && $oargs->{all}) {
3039             # Pre-set events and failed members of oargs to empty
3040             # arrays, if they are not set, yet.
3041             $oargs->{events} = [] unless ($oargs->{events});
3042             $oargs->{failed} = [] unless ($oargs->{failed});
3043             # When we're done with these checks, we swap permitted
3044             # with a reference to @disallowed.
3045             my @disallowed = ();
3046             foreach my $evt (@{$permitted}) {
3047                 # Check if we've already seen the event in this
3048                 # session and it failed.
3049                 if (grep {$_ eq $evt->{textcode}} @{$oargs->{failed}}) {
3050                     push(@disallowed, $evt);
3051                 } else {
3052                     # We have to check if the requestor has the
3053                     # override permission.
3054
3055                     # AppUtils::check_user_perms returns the perm if
3056                     # the user doesn't have it, undef if they do.
3057                     if ($apputils->check_user_perms($requestor->id, $requestor->ws_ou, $evt->{textcode} . '.override')) {
3058                         push(@disallowed, $evt);
3059                         push(@{$oargs->{failed}}, $evt->{textcode});
3060                     } else {
3061                         push(@{$oargs->{events}}, $evt->{textcode});
3062                     }
3063                 }
3064             }
3065             $permitted = \@disallowed;
3066         }
3067     }
3068
3069     my $age_protect_only = 0;
3070     if (@$permitted == 1 && @$permitted[0]->{textcode} eq 'ITEM_AGE_PROTECTED') {
3071         $age_protect_only = 1;
3072     }
3073
3074     return (
3075         (not scalar @$permitted), # true if permitted is an empty arrayref
3076         (   # XXX This test is of very dubious value; someone should figure
3077             # out what if anything is checking this value
3078             ($copy->circ_lib == $pickup_lib) and
3079             ($copy->status == OILS_COPY_STATUS_AVAILABLE)
3080         ),
3081         $permitted,
3082         $age_protect_only
3083     );
3084 }
3085
3086
3087
3088 sub find_nearest_permitted_hold {
3089
3090     my $class  = shift;
3091     my $editor = shift;     # CStoreEditor object
3092     my $copy   = shift;     # copy to target
3093     my $user   = shift;     # staff
3094     my $check_only = shift; # do no updates, just see if the copy could fulfill a hold
3095
3096     my $evt = OpenILS::Event->new('ACTION_HOLD_REQUEST_NOT_FOUND');
3097
3098     my $bc = $copy->barcode;
3099
3100     # find any existing holds that already target this copy
3101     my $old_holds = $editor->search_action_hold_request(
3102         {    current_copy => $copy->id,
3103             cancel_time  => undef,
3104             capture_time => undef
3105         }
3106     );
3107
3108     my $hold_stall_interval = $U->ou_ancestor_setting_value($user->ws_ou, OILS_SETTING_HOLD_SOFT_STALL);
3109
3110     $logger->info("circulator: searching for best hold at org ".$user->ws_ou.
3111         " and copy $bc with a hold stalling interval of ". ($hold_stall_interval || "(none)"));
3112
3113     my $fifo = $U->ou_ancestor_setting_value($user->ws_ou, 'circ.holds_fifo');
3114
3115     # the nearest_hold API call now needs this
3116     $copy->call_number($editor->retrieve_asset_call_number($copy->call_number))
3117         unless ref $copy->call_number;
3118
3119     # search for what should be the best holds for this copy to fulfill
3120     my $best_holds = $U->storagereq(
3121         "open-ils.storage.action.hold_request.nearest_hold.atomic", 
3122         $user->ws_ou, $copy, 100, $hold_stall_interval, $fifo );
3123
3124     # Add any pre-targeted holds to the list too? Unless they are already there, anyway.
3125     if ($old_holds) {
3126         for my $holdid (@$old_holds) {
3127             next unless $holdid;
3128             push(@$best_holds, $holdid) unless ( grep { ''.$holdid eq ''.$_ } @$best_holds );
3129         }
3130     }
3131
3132     unless(@$best_holds) {
3133         $logger->info("circulator: no suitable holds found for copy $bc");
3134         return (undef, $evt);
3135     }
3136
3137
3138     my $best_hold;
3139
3140     # for each potential hold, we have to run the permit script
3141     # to make sure the hold is actually permitted.
3142     my %reqr_cache;
3143     my %org_cache;
3144     for my $holdid (@$best_holds) {
3145         next unless $holdid;
3146         $logger->info("circulator: checking if hold $holdid is permitted for copy $bc");
3147
3148         my $hold = $editor->retrieve_action_hold_request($holdid) or next;
3149         # Force and recall holds bypass all rules
3150         if ($hold->hold_type eq 'R' || $hold->hold_type eq 'F') {
3151             $best_hold = $hold;
3152             last;
3153         }
3154         my $reqr = $reqr_cache{$hold->requestor} || $editor->retrieve_actor_user($hold->requestor);
3155         my $rlib = $org_cache{$hold->request_lib} || $editor->retrieve_actor_org_unit($hold->request_lib);
3156
3157         $reqr_cache{$hold->requestor} = $reqr;
3158         $org_cache{$hold->request_lib} = $rlib;
3159
3160         # see if this hold is permitted
3161         my $permitted = OpenILS::Utils::PermitHold::permit_copy_hold(
3162             {
3163                 patron_id   => $hold->usr,
3164                 requestor   => $reqr,
3165                 copy        => $copy,
3166                 pickup_lib  => $hold->pickup_lib,
3167                 request_lib => $rlib,
3168                 retarget    => 1
3169             }
3170         );
3171
3172         if( $permitted ) {
3173             $best_hold = $hold;
3174             last;
3175         }
3176     }
3177
3178
3179     unless( $best_hold ) { # no "good" permitted holds were found
3180         # we got nuthin
3181         $logger->info("circulator: no suitable holds found for copy $bc");
3182         return (undef, $evt);
3183     }
3184
3185     $logger->info("circulator: best hold ".$best_hold->id." found for copy $bc");
3186
3187     # indicate a permitted hold was found
3188     return $best_hold if $check_only;
3189
3190     # we've found a permitted hold.  we need to "grab" the copy
3191     # to prevent re-targeted holds (next part) from re-grabbing the copy
3192     $best_hold->current_copy($copy->id);
3193     $editor->update_action_hold_request($best_hold)
3194         or return (undef, $editor->event);
3195
3196
3197     my @retarget;
3198
3199     # re-target any other holds that already target this copy
3200     for my $old_hold (@$old_holds) {
3201         next if $old_hold->id eq $best_hold->id; # don't re-target the hold we want
3202         $logger->info("circulator: clearing current_copy and prev_check_time on hold ".
3203             $old_hold->id." after a better hold [".$best_hold->id."] was found");
3204         $old_hold->clear_current_copy;
3205         $old_hold->clear_prev_check_time;
3206         $editor->update_action_hold_request($old_hold)
3207             or return (undef, $editor->event);
3208         push(@retarget, $old_hold->id);
3209     }
3210
3211     return ($best_hold, undef, (@retarget) ? \@retarget : undef);
3212 }
3213
3214
3215
3216
3217
3218
3219 __PACKAGE__->register_method(
3220     method   => 'all_rec_holds',
3221     api_name => 'open-ils.circ.holds.retrieve_all_from_title',
3222 );
3223
3224 sub all_rec_holds {
3225     my( $self, $conn, $auth, $title_id, $args ) = @_;
3226
3227     my $e = new_editor(authtoken=>$auth);
3228     $e->checkauth or return $e->event;
3229     $e->allowed('VIEW_HOLD') or return $e->event;
3230
3231     $args ||= {};
3232     $args->{fulfillment_time} = undef; #  we don't want to see old fulfilled holds
3233     $args->{cancel_time} = undef;
3234
3235     my $resp = {
3236           metarecord_holds => []
3237         , title_holds      => []
3238         , volume_holds     => []
3239         , copy_holds       => []
3240         , recall_holds     => []
3241         , force_holds      => []
3242         , part_holds       => []
3243         , issuance_holds   => []
3244     };
3245
3246     my $mr_map = $e->search_metabib_metarecord_source_map({source => $title_id})->[0];
3247     if($mr_map) {
3248         $resp->{metarecord_holds} = $e->search_action_hold_request(
3249             {   hold_type => OILS_HOLD_TYPE_METARECORD,
3250                 target => $mr_map->metarecord,
3251                 %$args
3252             }, {idlist => 1}
3253         );
3254     }
3255
3256     $resp->{title_holds} = $e->search_action_hold_request(
3257         {
3258             hold_type => OILS_HOLD_TYPE_TITLE,
3259             target => $title_id,
3260             %$args
3261         }, {idlist=>1} );
3262
3263     my $parts = $e->search_biblio_monograph_part(
3264         {
3265             record => $title_id
3266         }, {idlist=>1} );
3267
3268     if (@$parts) {
3269         $resp->{part_holds} = $e->search_action_hold_request(
3270             {
3271                 hold_type => OILS_HOLD_TYPE_MONOPART,
3272                 target => $parts,
3273                 %$args
3274             }, {idlist=>1} );
3275     }
3276
3277     my $subs = $e->search_serial_subscription(
3278         { record_entry => $title_id }, {idlist=>1});
3279
3280     if (@$subs) {
3281         my $issuances = $e->search_serial_issuance(
3282             {subscription => $subs}, {idlist=>1}
3283         );
3284
3285         if (@$issuances) {
3286             $resp->{issuance_holds} = $e->search_action_hold_request(
3287                 {
3288                     hold_type => OILS_HOLD_TYPE_ISSUANCE,
3289                     target => $issuances,
3290                     %$args
3291                 }, {idlist=>1}
3292             );
3293         }
3294     }
3295
3296     my $vols = $e->search_asset_call_number(
3297         { record => $title_id, deleted => 'f' }, {idlist=>1});
3298
3299     return $resp unless @$vols;
3300
3301     $resp->{volume_holds} = $e->search_action_hold_request(
3302         {
3303             hold_type => OILS_HOLD_TYPE_VOLUME,
3304             target => $vols,
3305             %$args },
3306         {idlist=>1} );
3307
3308     my $copies = $e->search_asset_copy(
3309         { call_number => $vols, deleted => 'f' }, {idlist=>1});
3310
3311     return $resp unless @$copies;
3312
3313     $resp->{copy_holds} = $e->search_action_hold_request(
3314         {
3315             hold_type => OILS_HOLD_TYPE_COPY,
3316             target => $copies,
3317             %$args },
3318         {idlist=>1} );
3319
3320     $resp->{recall_holds} = $e->search_action_hold_request(
3321         {
3322             hold_type => OILS_HOLD_TYPE_RECALL,
3323             target => $copies,
3324             %$args },
3325         {idlist=>1} );
3326
3327     $resp->{force_holds} = $e->search_action_hold_request(
3328         {
3329             hold_type => OILS_HOLD_TYPE_FORCE,
3330             target => $copies,
3331             %$args },
3332         {idlist=>1} );
3333
3334     return $resp;
3335 }
3336
3337
3338
3339
3340
3341 __PACKAGE__->register_method(
3342     method        => 'uber_hold',
3343     authoritative => 1,
3344     api_name      => 'open-ils.circ.hold.details.retrieve'
3345 );
3346
3347 sub uber_hold {
3348     my($self, $client, $auth, $hold_id, $args) = @_;
3349     my $e = new_editor(authtoken=>$auth);
3350     $e->checkauth or return $e->event;
3351     return uber_hold_impl($e, $hold_id, $args);
3352 }
3353
3354 __PACKAGE__->register_method(
3355     method        => 'batch_uber_hold',
3356     authoritative => 1,
3357     stream        => 1,
3358     api_name      => 'open-ils.circ.hold.details.batch.retrieve'
3359 );
3360
3361 sub batch_uber_hold {
3362     my($self, $client, $auth, $hold_ids, $args) = @_;
3363     my $e = new_editor(authtoken=>$auth);
3364     $e->checkauth or return $e->event;
3365     $client->respond(uber_hold_impl($e, $_, $args)) for @$hold_ids;
3366     return undef;
3367 }
3368
3369 sub uber_hold_impl {
3370     my($e, $hold_id, $args) = @_;
3371     $args ||= {};
3372
3373     my $hold = $e->retrieve_action_hold_request(
3374         [
3375             $hold_id,
3376             {
3377                 flesh => 1,
3378                 flesh_fields => { ahr => [ 'current_copy', 'usr', 'notes' ] }
3379             }
3380         ]
3381     ) or return $e->event;
3382
3383     if($hold->usr->id ne $e->requestor->id) {
3384         # caller is asking for someone else's hold
3385         $e->allowed('VIEW_HOLD') or return $e->event;
3386         $hold->notes( # filter out any non-staff ("private") notes (unless marked as public)
3387             [ grep { $U->is_true($_->staff) or $U->is_true($_->pub) } @{$hold->notes} ] );
3388
3389     } else {
3390         # caller is asking for own hold, but may not have permission to view staff notes
3391         unless($e->allowed('VIEW_HOLD')) {
3392             $hold->notes( # filter out any staff notes (unless marked as public)
3393                 [ grep { !$U->is_true($_->staff) or $U->is_true($_->pub) } @{$hold->notes} ] );
3394         }
3395     }
3396
3397     my $user = $hold->usr;
3398     $hold->usr($user->id);
3399
3400
3401     my( $mvr, $volume, $copy, $issuance, $part, $bre ) = find_hold_mvr($e, $hold, $args->{suppress_mvr});
3402
3403     flesh_hold_notices([$hold], $e) unless $args->{suppress_notices};
3404     flesh_hold_transits([$hold]) unless $args->{suppress_transits};
3405
3406     my $details = retrieve_hold_queue_status_impl($e, $hold);
3407
3408     my $resp = {
3409         hold    => $hold,
3410         bre_id  => $bre->id,
3411         ($copy     ? (copy           => $copy)     : ()),
3412         ($volume   ? (volume         => $volume)   : ()),
3413         ($issuance ? (issuance       => $issuance) : ()),
3414         ($part     ? (part           => $part)     : ()),
3415         ($args->{include_bre}  ?  (bre => $bre)    : ()),
3416         ($args->{suppress_mvr} ?  () : (mvr => $mvr)),
3417         %$details
3418     };
3419
3420     $resp->{copy}->location(
3421         $e->retrieve_asset_copy_location($resp->{copy}->location))
3422         if $resp->{copy} and $args->{flesh_acpl};
3423
3424     unless($args->{suppress_patron_details}) {
3425         my $card = $e->retrieve_actor_card($user->card) or return $e->event;
3426         $resp->{patron_first}   = $user->first_given_name,
3427         $resp->{patron_last}    = $user->family_name,
3428         $resp->{patron_barcode} = $card->barcode,
3429         $resp->{patron_alias}   = $user->alias,
3430     };
3431
3432     return $resp;
3433 }
3434
3435
3436
3437 # -----------------------------------------------------
3438 # Returns the MVR object that represents what the
3439 # hold is all about
3440 # -----------------------------------------------------
3441 sub find_hold_mvr {
3442     my( $e, $hold, $no_mvr ) = @_;
3443
3444     my $tid;
3445     my $copy;
3446     my $volume;
3447     my $issuance;
3448     my $part;
3449
3450     if( $hold->hold_type eq OILS_HOLD_TYPE_METARECORD ) {
3451         my $mr = $e->retrieve_metabib_metarecord($hold->target)
3452             or return $e->event;
3453         $tid = $mr->master_record;
3454
3455     } elsif( $hold->hold_type eq OILS_HOLD_TYPE_TITLE ) {
3456         $tid = $hold->target;
3457
3458     } elsif( $hold->hold_type eq OILS_HOLD_TYPE_VOLUME ) {
3459         $volume = $e->retrieve_asset_call_number($hold->target)
3460             or return $e->event;
3461         $tid = $volume->record;
3462
3463     } elsif( $hold->hold_type eq OILS_HOLD_TYPE_ISSUANCE ) {
3464         $issuance = $e->retrieve_serial_issuance([
3465             $hold->target,
3466             {flesh => 1, flesh_fields => {siss => [ qw/subscription/ ]}}
3467         ]) or return $e->event;
3468
3469         $tid = $issuance->subscription->record_entry;
3470
3471     } elsif( $hold->hold_type eq OILS_HOLD_TYPE_MONOPART ) {
3472         $part = $e->retrieve_biblio_monograph_part([
3473             $hold->target
3474         ]) or return $e->event;
3475
3476         $tid = $part->record;
3477
3478     } 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 ) {
3479         $copy = $e->retrieve_asset_copy([
3480             $hold->target,
3481             {flesh => 1, flesh_fields => {acp => ['call_number']}}
3482         ]) or return $e->event;
3483
3484         $volume = $copy->call_number;
3485         $tid = $volume->record;
3486     }
3487
3488     if(!$copy and ref $hold->current_copy ) {
3489         $copy = $hold->current_copy;
3490         $hold->current_copy($copy->id);
3491     }
3492
3493     if(!$volume and $copy) {
3494         $volume = $e->retrieve_asset_call_number($copy->call_number);
3495     }
3496
3497     # TODO return metarcord mvr for M holds
3498     my $title = $e->retrieve_biblio_record_entry($tid);
3499     return ( ($no_mvr) ? undef : $U->record_to_mvr($title), $volume, $copy, $issuance, $part, $title );
3500 }
3501
3502 __PACKAGE__->register_method(
3503     method    => 'clear_shelf_cache',
3504     api_name  => 'open-ils.circ.hold.clear_shelf.get_cache',
3505     stream    => 1,
3506     signature => {
3507         desc => q/
3508             Returns the holds processed with the given cache key
3509         /
3510     }
3511 );
3512
3513 sub clear_shelf_cache {
3514     my($self, $client, $auth, $cache_key, $chunk_size) = @_;
3515     my $e = new_editor(authtoken => $auth, xact => 1);
3516     return $e->die_event unless $e->checkauth and $e->allowed('VIEW_HOLD');
3517
3518     $chunk_size ||= 25;
3519     $client->max_chunk_size($chunk_size);
3520
3521     my $hold_data = OpenSRF::Utils::Cache->new('global')->get_cache($cache_key);
3522
3523     if (!$hold_data) {
3524         $logger->info("no hold data found in cache"); # XXX TODO return event
3525         $e->rollback;
3526         return undef;
3527     }
3528
3529     my $maximum = 0;
3530     foreach (keys %$hold_data) {
3531         $maximum += scalar(@{ $hold_data->{$_} });
3532     }
3533     $client->respond({"maximum" => $maximum, "progress" => 0});
3534
3535     for my $action (sort keys %$hold_data) {
3536         while (@{$hold_data->{$action}}) {
3537             my @hid_chunk = splice @{$hold_data->{$action}}, 0, $chunk_size;
3538
3539             my $result_chunk = $e->json_query({
3540                 "select" => {
3541                     "acp" => ["barcode"],
3542                     "au" => [qw/
3543                         first_given_name second_given_name family_name alias
3544                     /],
3545                     "acn" => ["label"],
3546                     "acnp" => [{column => "label", alias => "prefix"}],
3547                     "acns" => [{column => "label", alias => "suffix"}],
3548                     "bre" => ["marc"],
3549                     "acpl" => ["name"],
3550                     "ahr" => ["id"]
3551                 },
3552                 "from" => {
3553                     "ahr" => {
3554                         "acp" => {
3555                             "field" => "id", "fkey" => "current_copy",
3556                             "join" => {
3557                                 "acn" => {
3558                                     "field" => "id", "fkey" => "call_number",
3559                                     "join" => {
3560                                         "bre" => {
3561                                             "field" => "id", "fkey" => "record"
3562                                         },
3563                                         "acnp" => {
3564                                             "field" => "id", "fkey" => "prefix"
3565                                         },
3566                                         "acns" => {
3567                                             "field" => "id", "fkey" => "suffix"
3568                                         }
3569                                     }
3570                                 },
3571                                 "acpl" => {"field" => "id", "fkey" => "location"}
3572                             }
3573                         },
3574                         "au" => {"field" => "id", "fkey" => "usr"}
3575                     }
3576                 },
3577                 "where" => {"+ahr" => {"id" => \@hid_chunk}}
3578             }, {"substream" => 1}) or return $e->die_event;
3579
3580             $client->respond([
3581                 map {
3582                     +{"action" => $action, "hold_details" => $_}
3583                 } @$result_chunk
3584             ]);
3585         }
3586     }
3587
3588     $e->rollback;
3589     return undef;
3590 }
3591
3592
3593 __PACKAGE__->register_method(
3594     method    => 'clear_shelf_process',
3595     stream    => 1,
3596     api_name  => 'open-ils.circ.hold.clear_shelf.process',
3597     signature => {
3598         desc => q/
3599             1. Find all holds that have expired on the holds shelf
3600             2. Cancel the holds
3601             3. If a clear-shelf status is configured, put targeted copies into this status
3602             4. Divide copies into 3 groups: items to transit, items to reshelve, and items
3603                 that are needed for holds.  No subsequent action is taken on the holds
3604                 or items after grouping.
3605         /
3606     }
3607 );
3608
3609 sub clear_shelf_process {
3610     my($self, $client, $auth, $org_id, $match_copy, $chunk_size) = @_;
3611
3612     my $e = new_editor(authtoken=>$auth);
3613     $e->checkauth or return $e->die_event;
3614     my $cache = OpenSRF::Utils::Cache->new('global');
3615
3616     $org_id ||= $e->requestor->ws_ou;
3617     $e->allowed('UPDATE_HOLD', $org_id) or return $e->die_event;
3618
3619     my $copy_status = $U->ou_ancestor_setting_value($org_id, 'circ.holds.clear_shelf.copy_status');
3620
3621     my @hold_ids = $self->method_lookup(
3622         "open-ils.circ.captured_holds.id_list.expired_on_shelf.retrieve"
3623     )->run($auth, $org_id, $match_copy);
3624
3625     $e->xact_begin;
3626
3627     my @holds;
3628     my @canceled_holds; # newly canceled holds
3629     $chunk_size ||= 25; # chunked status updates
3630     $client->max_chunk_size($chunk_size);
3631
3632     my $counter = 0;
3633     for my $hold_id (@hold_ids) {
3634
3635         $logger->info("Clear shelf processing hold $hold_id");
3636
3637         my $hold = $e->retrieve_action_hold_request([
3638             $hold_id, {
3639                 flesh => 1,
3640                 flesh_fields => {ahr => ['current_copy']}
3641             }
3642         ]);
3643
3644         if (!$hold->cancel_time) { # may be canceled but still on the holds shelf
3645             $hold->cancel_time('now');
3646             $hold->cancel_cause(2); # Hold Shelf expiration
3647             $e->update_action_hold_request($hold) or return $e->die_event;
3648             push(@canceled_holds, $hold_id);
3649         }
3650
3651         my $copy = $hold->current_copy;
3652
3653         if($copy_status or $copy_status == 0) {
3654             # if a clear-shelf copy status is defined, update the copy
3655             $copy->status($copy_status);
3656             $copy->edit_date('now');
3657             $copy->editor($e->requestor->id);
3658             $e->update_asset_copy($copy) or return $e->die_event;
3659         }
3660
3661         push(@holds, $hold);
3662         $client->respond({maximum => scalar(@holds), progress => $counter}) if ( (++$counter % $chunk_size) == 0);
3663     }
3664
3665     if ($e->commit) {
3666
3667         my %cache_data = (
3668             hold => [],
3669             transit => [],
3670             shelf => [],
3671             pl_changed => pickup_lib_changed_on_shelf_holds($e, $org_id, \@hold_ids)
3672         );
3673
3674         for my $hold (@holds) {
3675
3676             my $copy = $hold->current_copy;
3677             my ($alt_hold) = __PACKAGE__->find_nearest_permitted_hold($e, $copy, $e->requestor, 1);
3678
3679             if($alt_hold and !$match_copy) {
3680
3681                 push(@{$cache_data{hold}}, $hold->id); # copy is needed for a hold
3682
3683             } elsif($copy->circ_lib != $e->requestor->ws_ou) {
3684
3685                 push(@{$cache_data{transit}}, $hold->id); # copy needs to transit
3686
3687             } else {
3688
3689                 push(@{$cache_data{shelf}}, $hold->id); # copy needs to go back to the shelf
3690             }
3691         }
3692
3693         my $cache_key = md5_hex(time . $$ . rand());
3694         $logger->info("clear_shelf_cache: storing under $cache_key");
3695         $cache->put_cache($cache_key, \%cache_data, 7200); # TODO: 2 hours.  configurable?
3696
3697         # tell the client we're done
3698         $client->respond_complete({cache_key => $cache_key});
3699
3700         # ------------
3701         # fire off the hold cancelation trigger and wait for response so don't flood the service
3702
3703         # refetch the holds to pick up the caclulated cancel_time,
3704         # which may be needed by Action/Trigger
3705         $e->xact_begin;
3706         my $updated_holds = [];
3707         $updated_holds = $e->search_action_hold_request({id => \@canceled_holds}, {substream => 1}) if (@canceled_holds > 0);
3708         $e->rollback;
3709
3710         $U->create_events_for_hook(
3711             'hold_request.cancel.expire_holds_shelf',
3712             $_, $org_id, undef, undef, 1) for @$updated_holds;
3713
3714     } else {
3715         # tell the client we're done
3716         $client->respond_complete;
3717     }
3718 }
3719
3720 # returns IDs for holds that are on the holds shelf but 
3721 # have had their pickup_libs change while on the shelf.
3722 sub pickup_lib_changed_on_shelf_holds {
3723     my $e = shift;
3724     my $org_id = shift;
3725     my $ignore_holds = shift;
3726     $ignore_holds = [$ignore_holds] if !ref($ignore_holds);
3727
3728     my $query = {
3729         select => { alhr => ['id'] },
3730         from   => {
3731             alhr => {
3732                 acp => {
3733                     field => 'id',
3734                     fkey  => 'current_copy'
3735                 },
3736             }
3737         },
3738         where => {
3739             '+acp' => { status => OILS_COPY_STATUS_ON_HOLDS_SHELF },
3740             '+alhr' => {
3741                 capture_time     => { "!=" => undef },
3742                 fulfillment_time => undef,
3743                 current_shelf_lib => $org_id,
3744                 pickup_lib => {'!='  => {'+alhr' => 'current_shelf_lib'}}
3745             }
3746         }
3747     };
3748
3749     $query->{where}->{'+alhr'}->{id} =
3750         {'not in' => $ignore_holds} if @$ignore_holds;
3751
3752     my $hold_ids = $e->json_query($query);
3753     return [ map { $_->{id} } @$hold_ids ];
3754 }
3755
3756 __PACKAGE__->register_method(
3757     method    => 'usr_hold_summary',
3758     api_name  => 'open-ils.circ.holds.user_summary',
3759     signature => q/
3760         Returns a summary of holds statuses for a given user
3761     /
3762 );
3763
3764 sub usr_hold_summary {
3765     my($self, $conn, $auth, $user_id) = @_;
3766
3767     my $e = new_editor(authtoken=>$auth);
3768     $e->checkauth or return $e->event;
3769     $e->allowed('VIEW_HOLD') or return $e->event;
3770
3771     my $holds = $e->search_action_hold_request(
3772         {
3773             usr =>  $user_id ,
3774             fulfillment_time => undef,
3775             cancel_time      => undef,
3776         }
3777     );
3778
3779     my %summary = (1 => 0, 2 => 0, 3 => 0, 4 => 0);
3780     $summary{_hold_status($e, $_)} += 1 for @$holds;
3781     return \%summary;
3782 }
3783
3784
3785
3786 __PACKAGE__->register_method(
3787     method    => 'hold_has_copy_at',
3788     api_name  => 'open-ils.circ.hold.has_copy_at',
3789     signature => {
3790         desc   =>
3791                 'Returns the ID of the found copy and name of the shelving location if there is ' .
3792                 'an available copy at the specified org unit.  Returns empty hash otherwise.  '   .
3793                 'The anticipated use for this method is to determine whether an item is '         .
3794                 'available at the library where the user is placing the hold (or, alternatively, '.
3795                 'at the pickup library) to encourage bypassing the hold placement and just '      .
3796                 'checking out the item.' ,
3797         params => [
3798             { desc => 'Authentication Token', type => 'string' },
3799             { desc => 'Method Arguments.  Options include: hold_type, hold_target, org_unit.  '
3800                     . 'hold_type is the hold type code (T, V, C, M, ...).  '
3801                     . 'hold_target is the identifier of the hold target object.  '
3802                     . 'org_unit is org unit ID.',
3803               type => 'object'
3804             }
3805         ],
3806         return => {
3807             desc => q/Result hash like { "copy" : copy_id, "location" : location_name }, empty hash on misses, event on error./,
3808             type => 'object'
3809         }
3810     }
3811 );
3812
3813 sub hold_has_copy_at {
3814     my($self, $conn, $auth, $args) = @_;
3815
3816     my $e = new_editor(authtoken=>$auth);
3817     $e->checkauth or return $e->event;
3818
3819     my $hold_type   = $$args{hold_type};
3820     my $hold_target = $$args{hold_target};
3821     my $org_unit    = $$args{org_unit};
3822
3823     my $query = {
3824         select => {acp => ['id'], acpl => ['name']},
3825         from   => {
3826             acp => {
3827                 acpl => {
3828                     field => 'id',
3829                     filter => { holdable => 't', deleted => 'f' },
3830                     fkey => 'location'
3831                 },
3832                 ccs  => {field => 'id', filter => { holdable => 't'}, fkey => 'status'  }
3833             }
3834         },
3835         where => {'+acp' => { circulate => 't', deleted => 'f', holdable => 't', circ_lib => $org_unit, status => [0,7]}},
3836         limit => 1
3837     };
3838
3839     if($hold_type eq 'C' or $hold_type eq 'F' or $hold_type eq 'R') {
3840
3841         $query->{where}->{'+acp'}->{id} = $hold_target;
3842
3843     } elsif($hold_type eq 'V') {
3844
3845         $query->{where}->{'+acp'}->{call_number} = $hold_target;
3846
3847     } elsif($hold_type eq 'P') {
3848
3849         $query->{from}->{acp}->{acpm} = {
3850             field  => 'target_copy',
3851             fkey   => 'id',
3852             filter => {part => $hold_target},
3853         };
3854
3855     } elsif($hold_type eq 'I') {
3856
3857         $query->{from}->{acp}->{sitem} = {
3858             field  => 'unit',
3859             fkey   => 'id',
3860             filter => {issuance => $hold_target},
3861         };
3862
3863     } elsif($hold_type eq 'T') {
3864
3865         $query->{from}->{acp}->{acn} = {
3866             field  => 'id',
3867             fkey   => 'call_number',
3868             'join' => {
3869                 bre => {
3870                     field  => 'id',
3871                     filter => {id => $hold_target},
3872                     fkey   => 'record'
3873                 }
3874             }
3875         };
3876
3877     } else {
3878
3879         $query->{from}->{acp}->{acn} = {
3880             field => 'id',
3881             fkey  => 'call_number',
3882             join  => {
3883                 bre => {
3884                     field => 'id',
3885                     fkey  => 'record',
3886                     join  => {
3887                         mmrsm => {
3888                             field  => 'source',
3889                             fkey   => 'id',
3890                             filter => {metarecord => $hold_target},
3891                         }
3892                     }
3893                 }
3894             }
3895         };
3896     }
3897
3898     my $res = $e->json_query($query)->[0] or return {};
3899     return {copy => $res->{id}, location => $res->{name}} if $res;
3900 }
3901
3902
3903 # returns true if the user already has an item checked out
3904 # that could be used to fulfill the requested hold.
3905 sub hold_item_is_checked_out {
3906     my($e, $user_id, $hold_type, $hold_target) = @_;
3907
3908     my $query = {
3909         select => {acp => ['id']},
3910         from   => {acp => {}},
3911         where  => {
3912             '+acp' => {
3913                 id => {
3914                     in => { # copies for circs the user has checked out
3915                         select => {circ => ['target_copy']},
3916                         from   => 'circ',
3917                         where  => {
3918                             usr => $user_id,
3919                             checkin_time => undef,
3920                             '-or' => [
3921                                 {stop_fines => ["MAXFINES","LONGOVERDUE"]},
3922                                 {stop_fines => undef}
3923                             ],
3924                         }
3925                     }
3926                 }
3927             }
3928         },
3929         limit => 1
3930     };
3931
3932     if($hold_type eq 'C' || $hold_type eq 'R' || $hold_type eq 'F') {
3933
3934         $query->{where}->{'+acp'}->{id}->{in}->{where}->{'target_copy'} = $hold_target;
3935
3936     } elsif($hold_type eq 'V') {
3937
3938         $query->{where}->{'+acp'}->{call_number} = $hold_target;
3939
3940      } elsif($hold_type eq 'P') {
3941
3942         $query->{from}->{acp}->{acpm} = {
3943             field  => 'target_copy',
3944             fkey   => 'id',
3945             filter => {part => $hold_target},
3946         };
3947
3948      } elsif($hold_type eq 'I') {
3949
3950         $query->{from}->{acp}->{sitem} = {
3951             field  => 'unit',
3952             fkey   => 'id',
3953             filter => {issuance => $hold_target},
3954         };
3955
3956     } elsif($hold_type eq 'T') {
3957
3958         $query->{from}->{acp}->{acn} = {
3959             field  => 'id',
3960             fkey   => 'call_number',
3961             'join' => {
3962                 bre => {
3963                     field  => 'id',
3964                     filter => {id => $hold_target},
3965                     fkey   => 'record'
3966                 }
3967             }
3968         };
3969
3970     } else {
3971
3972         $query->{from}->{acp}->{acn} = {
3973             field => 'id',
3974             fkey => 'call_number',
3975             join => {
3976                 bre => {
3977                     field => 'id',
3978                     fkey => 'record',
3979                     join => {
3980                         mmrsm => {
3981                             field => 'source',
3982                             fkey => 'id',
3983                             filter => {metarecord => $hold_target},
3984                         }
3985                     }
3986                 }
3987             }
3988         };
3989     }
3990
3991     return $e->json_query($query)->[0];
3992 }
3993
3994 __PACKAGE__->register_method(
3995     method    => 'change_hold_title',
3996     api_name  => 'open-ils.circ.hold.change_title',
3997     signature => {
3998         desc => q/
3999             Updates all title level holds targeting the specified bibs to point a new bib./,
4000         params => [
4001             { desc => 'Authentication Token', type => 'string' },
4002             { desc => 'New Target Bib Id',    type => 'number' },
4003             { desc => 'Old Target Bib Ids',   type => 'array'  },
4004         ],
4005         return => { desc => '1 on success' }
4006     }
4007 );
4008
4009 __PACKAGE__->register_method(
4010     method    => 'change_hold_title_for_specific_holds',
4011     api_name  => 'open-ils.circ.hold.change_title.specific_holds',
4012     signature => {
4013         desc => q/
4014             Updates specified holds to target new bib./,
4015         params => [
4016             { desc => 'Authentication Token', type => 'string' },
4017             { desc => 'New Target Bib Id',    type => 'number' },
4018             { desc => 'Holds Ids for holds to update',   type => 'array'  },
4019         ],
4020         return => { desc => '1 on success' }
4021     }
4022 );
4023
4024
4025 sub change_hold_title {
4026     my( $self, $client, $auth, $new_bib_id, $bib_ids ) = @_;
4027
4028     my $e = new_editor(authtoken=>$auth, xact=>1);
4029     return $e->die_event unless $e->checkauth;
4030
4031     my $holds = $e->search_action_hold_request(
4032         [
4033             {
4034                 cancel_time      => undef,
4035                 fulfillment_time => undef,
4036                 hold_type        => 'T',
4037                 target           => $bib_ids
4038             },
4039             {
4040                 flesh        => 1,
4041                 flesh_fields => { ahr => ['usr'] }
4042             }
4043         ],
4044         { substream => 1 }
4045     );
4046
4047     for my $hold (@$holds) {
4048         $e->allowed('UPDATE_HOLD', $hold->usr->home_ou) or return $e->die_event;
4049         $logger->info("Changing hold " . $hold->id . " target from " . $hold->target . " to $new_bib_id in title hold target change");
4050         $hold->target( $new_bib_id );
4051         $e->update_action_hold_request($hold) or return $e->die_event;
4052     }
4053
4054     $e->commit;
4055
4056     _reset_hold($self, $e->requestor, $_) for @$holds;
4057
4058     return 1;
4059 }
4060
4061 sub change_hold_title_for_specific_holds {
4062     my( $self, $client, $auth, $new_bib_id, $hold_ids ) = @_;
4063
4064     my $e = new_editor(authtoken=>$auth, xact=>1);
4065     return $e->die_event unless $e->checkauth;
4066
4067     my $holds = $e->search_action_hold_request(
4068         [
4069             {
4070                 cancel_time      => undef,
4071                 fulfillment_time => undef,
4072                 hold_type        => 'T',
4073                 id               => $hold_ids
4074             },
4075             {
4076                 flesh        => 1,
4077                 flesh_fields => { ahr => ['usr'] }
4078             }
4079         ],
4080         { substream => 1 }
4081     );
4082
4083     for my $hold (@$holds) {
4084         $e->allowed('UPDATE_HOLD', $hold->usr->home_ou) or return $e->die_event;
4085         $logger->info("Changing hold " . $hold->id . " target from " . $hold->target . " to $new_bib_id in title hold target change");
4086         $hold->target( $new_bib_id );
4087         $e->update_action_hold_request($hold) or return $e->die_event;
4088     }
4089
4090     $e->commit;
4091
4092     _reset_hold($self, $e->requestor, $_) for @$holds;
4093
4094     return 1;
4095 }
4096
4097 __PACKAGE__->register_method(
4098     method    => 'rec_hold_count',
4099     api_name  => 'open-ils.circ.bre.holds.count',
4100     signature => {
4101         desc => q/Returns the total number of holds that target the
4102             selected bib record or its associated copies and call_numbers/,
4103         params => [
4104             { desc => 'Bib ID', type => 'number' },
4105             { desc => q/Optional arguments.  Supported arguments include:
4106                 "pickup_lib_descendant" -> limit holds to those whose pickup
4107                 library is equal to or is a child of the provided org unit/,
4108                 type => 'object'
4109             }
4110         ],
4111         return => {desc => 'Hold count', type => 'number'}
4112     }
4113 );
4114
4115 __PACKAGE__->register_method(
4116     method    => 'rec_hold_count',
4117     api_name  => 'open-ils.circ.mmr.holds.count',
4118     signature => {
4119         desc => q/Returns the total number of holds that target the
4120             selected metarecord or its associated copies, call_numbers, and bib records/,
4121         params => [
4122             { desc => 'Metarecord ID', type => 'number' },
4123         ],
4124         return => {desc => 'Hold count', type => 'number'}
4125     }
4126 );
4127
4128 # XXX Need to add type I holds to these counts
4129 sub rec_hold_count {
4130     my($self, $conn, $target_id, $args) = @_;
4131     $args ||= {};
4132
4133     my $mmr_join = {
4134         mmrsm => {
4135             field => 'source',
4136             fkey => 'id',
4137             filter => {metarecord => $target_id}
4138         }
4139     };
4140
4141     my $bre_join = {
4142         bre => {
4143             field => 'id',
4144             filter => { id => $target_id },
4145             fkey => 'record'
4146         }
4147     };
4148
4149     if($self->api_name =~ /mmr/) {
4150         delete $bre_join->{bre}->{filter};
4151         $bre_join->{bre}->{join} = $mmr_join;
4152     }
4153
4154     my $cn_join = {
4155         acn => {
4156             field => 'id',
4157             fkey => 'call_number',
4158             join => $bre_join
4159         }
4160     };
4161
4162     my $query = {
4163         select => {ahr => [{column => 'id', transform => 'count', alias => 'count'}]},
4164         from => 'ahr',
4165         where => {
4166             '+ahr' => {
4167                 cancel_time => undef,
4168                 fulfillment_time => undef,
4169                 '-or' => [
4170                     {
4171                         '-and' => {
4172                             hold_type => [qw/C F R/],
4173                             target => {
4174                                 in => {
4175                                     select => {acp => ['id']},
4176                                     from => { acp => $cn_join }
4177                                 }
4178                             }
4179                         }
4180                     },
4181                     {
4182                         '-and' => {
4183                             hold_type => 'V',
4184                             target => {
4185                                 in => {
4186                                     select => {acn => ['id']},
4187                                     from => {acn => $bre_join}
4188                                 }
4189                             }
4190                         }
4191                     },
4192                     {
4193                         '-and' => {
4194                             hold_type => 'P',
4195                             target => {
4196                                 in => {
4197                                     select => {bmp => ['id']},
4198                                     from => {bmp => $bre_join}
4199                                 }
4200                             }
4201                         }
4202                     },
4203                     {
4204                         '-and' => {
4205                             hold_type => 'T',
4206                             target => $target_id
4207                         }
4208                     }
4209                 ]
4210             }
4211         }
4212     };
4213
4214     if($self->api_name =~ /mmr/) {
4215         $query->{where}->{'+ahr'}->{'-or'}->[3] = {
4216             '-and' => {
4217                 hold_type => 'T',
4218                 target => {
4219                     in => {
4220                         select => {bre => ['id']},
4221                         from => {bre => $mmr_join}
4222                     }
4223                 }
4224             }
4225         };
4226
4227         $query->{where}->{'+ahr'}->{'-or'}->[4] = {
4228             '-and' => {
4229                 hold_type => 'M',
4230                 target => $target_id
4231             }
4232         };
4233     }
4234
4235
4236     if (my $pld = $args->{pickup_lib_descendant}) {
4237
4238         my $top_ou = new_editor()->search_actor_org_unit(
4239             {parent_ou => undef}
4240         )->[0]; # XXX Assumes single root node. Not alone in this...
4241
4242         $query->{where}->{'+ahr'}->{pickup_lib} = {
4243             in => {
4244                 select  => {aou => [{ 
4245                     column => 'id', 
4246                     transform => 'actor.org_unit_descendants', 
4247                     result_field => 'id' 
4248                 }]},
4249                 from    => 'aou',
4250                 where   => {id => $pld}
4251             }
4252         } if ($pld != $top_ou->id);
4253     }
4254
4255
4256     return new_editor()->json_query($query)->[0]->{count};
4257 }
4258
4259 # A helper function to calculate a hold's expiration time at a given
4260 # org_unit. Takes the org_unit as an argument and returns either the
4261 # hold expire time as an ISO8601 string or undef if there is no hold
4262 # expiration interval set for the subject ou.
4263 sub calculate_expire_time
4264 {
4265     my $ou = shift;
4266     my $interval = $U->ou_ancestor_setting_value($ou, OILS_SETTING_HOLD_EXPIRE);
4267     if($interval) {
4268         my $date = DateTime->now->add(seconds => OpenSRF::Utils::interval_to_seconds($interval));
4269         return $U->epoch2ISO8601($date->epoch);
4270     }
4271     return undef;
4272 }
4273
4274
4275 __PACKAGE__->register_method(
4276     method    => 'mr_hold_filter_attrs',
4277     api_name  => 'open-ils.circ.mmr.holds.filters',
4278     authoritative => 1,
4279     stream => 1,
4280     signature => {
4281         desc => q/
4282             Returns the set of available formats and languages for the
4283             constituent records of the provided metarcord.
4284             If an array of hold IDs is also provided, information about
4285             each is returned as well.  This information includes:
4286             1. a slightly easier to read version of holdable_formats
4287             2. attributes describing the set of format icons included
4288                in the set of desired, constituent records.
4289         /,
4290         params => [
4291             {desc => 'Metarecord ID', type => 'number'},
4292             {desc => 'Context Org ID', type => 'number'},
4293             {desc => 'Hold ID List', type => 'array'},
4294         ],
4295         return => {
4296             desc => q/
4297                 Stream of objects.  The first will have a 'metarecord' key
4298                 containing non-hold-specific metarecord information, subsequent
4299                 responses will contain a 'hold' key containing hold-specific
4300                 information
4301             /, 
4302             type => 'object'
4303         }
4304     }
4305 );
4306
4307 sub mr_hold_filter_attrs { 
4308     my ($self, $client, $mr_id, $org_id, $hold_ids) = @_;
4309     my $e = new_editor();
4310
4311     # by default, return MR / hold attributes for all constituent
4312     # records with holdable copies.  If there is a hard boundary,
4313     # though, limit to records with copies within the boundary,
4314     # since anything outside the boundary can never be held.
4315     my $org_depth = 0;
4316     if ($org_id) {
4317         $org_depth = $U->ou_ancestor_setting_value(
4318             $org_id, OILS_SETTING_HOLD_HARD_BOUNDARY) || 0;
4319     }
4320
4321     # get all org-scoped records w/ holdable copies for this metarecord
4322     my ($bre_ids) = $self->method_lookup(
4323         'open-ils.circ.holds.metarecord.filtered_records')->run(
4324             $mr_id, undef, $org_id, $org_depth);
4325
4326     my $item_lang_attr = 'item_lang'; # configurable?
4327     my $format_attr = $e->retrieve_config_global_flag(
4328         'opac.metarecord.holds.format_attr')->value;
4329
4330     # helper sub for fetching ccvms for a batch of record IDs
4331     sub get_batch_ccvms {
4332         my ($e, $attr, $bre_ids) = @_;
4333         return [] unless $bre_ids and @$bre_ids;
4334         my $vals = $e->search_metabib_record_attr_flat({
4335             attr => $attr,
4336             id => $bre_ids
4337         });
4338         return [] unless @$vals;
4339         return $e->search_config_coded_value_map({
4340             ctype => $attr,
4341             code => [map {$_->value} @$vals]
4342         });
4343     }
4344
4345     my $langs = get_batch_ccvms($e, $item_lang_attr, $bre_ids);
4346     my $formats = get_batch_ccvms($e, $format_attr, $bre_ids);
4347
4348     $client->respond({
4349         metarecord => {
4350             id => $mr_id,
4351             formats => $formats,
4352             langs => $langs
4353         }
4354     });
4355
4356     return unless $hold_ids;
4357     my $icon_attr = $e->retrieve_config_global_flag('opac.icon_attr');
4358     $icon_attr = $icon_attr ? $icon_attr->value : '';
4359
4360     for my $hold_id (@$hold_ids) {
4361         my $hold = $e->retrieve_action_hold_request($hold_id) 
4362             or return $e->event;
4363
4364         next unless $hold->hold_type eq 'M';
4365
4366         my $resp = {
4367             hold => {
4368                 id => $hold_id,
4369                 formats => [],
4370                 langs => []
4371             }
4372         };
4373
4374         # collect the ccvm's for the selected formats / language
4375         # (i.e. the holdable formats) on the MR.
4376         # this assumes a two-key structure for format / language,
4377         # though no assumption is made about the keys themselves.
4378         my $hformats = OpenSRF::Utils::JSON->JSON2perl($hold->holdable_formats);
4379         my $lang_vals = [];
4380         my $format_vals = [];
4381         for my $val (values %$hformats) {
4382             # val is either a single ccvm or an array of them
4383             $val = [$val] unless ref $val eq 'ARRAY';
4384             for my $node (@$val) {
4385                 push (@$lang_vals, $node->{_val})   
4386                     if $node->{_attr} eq $item_lang_attr; 
4387                 push (@$format_vals, $node->{_val})   
4388                     if $node->{_attr} eq $format_attr;
4389             }
4390         }
4391
4392         # fetch the ccvm's for consistency with the {metarecord} blob
4393         $resp->{hold}{formats} = $e->search_config_coded_value_map({
4394             ctype => $format_attr, code => $format_vals});
4395         $resp->{hold}{langs} = $e->search_config_coded_value_map({
4396             ctype => $item_lang_attr, code => $lang_vals});
4397
4398         # find all of the bib records within this metarcord whose 
4399         # format / language match the holdable formats on the hold
4400         my ($bre_ids) = $self->method_lookup(
4401             'open-ils.circ.holds.metarecord.filtered_records')->run(
4402                 $hold->target, $hold->holdable_formats, 
4403                 $hold->selection_ou, $hold->selection_depth);
4404
4405         # now find all of the 'icon' attributes for the records
4406         $resp->{hold}{icons} = get_batch_ccvms($e, $icon_attr, $bre_ids);
4407         $client->respond($resp);
4408     }
4409
4410     return;
4411 }
4412
4413 1;