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