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