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