]> git.evergreen-ils.org Git - working/Evergreen.git/blob - Open-ILS/src/perlmods/lib/OpenILS/Application/Circ/Holds.pm
66b6391cfe9099467aaa3d4b516900069d01c6db
[working/Evergreen.git] / Open-ILS / src / perlmods / lib / OpenILS / Application / Circ / Holds.pm
1 # ---------------------------------------------------------------
2 # Copyright (C) 2005  Georgia Public Library Service
3 # Bill Erickson <highfalutin@gmail.com>
4
5 # This program is free software; you can redistribute it and/or
6 # modify it under the terms of the GNU General Public License
7 # as published by the Free Software Foundation; either version 2
8 # of the License, or (at your option) any later version.
9
10 # This program is distributed in the hope that it will be useful,
11 # but WITHOUT ANY WARRANTY; without even the implied warranty of
12 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
13 # GNU General Public License for more details.
14 # ---------------------------------------------------------------
15
16
17 package OpenILS::Application::Circ::Holds;
18 use base qw/OpenILS::Application/;
19 use strict; use warnings;
20 use OpenILS::Application::AppUtils;
21 use DateTime;
22 use Data::Dumper;
23 use OpenSRF::EX qw(:try);
24 use OpenILS::Perm;
25 use OpenILS::Event;
26 use OpenSRF::Utils;
27 use OpenSRF::Utils::Logger qw(:logger);
28 use OpenILS::Utils::CStoreEditor q/:funcs/;
29 use OpenILS::Utils::PermitHold;
30 use OpenSRF::Utils::SettingsClient;
31 use OpenILS::Const qw/:const/;
32 use OpenILS::Application::Circ::Transit;
33 use OpenILS::Application::Actor::Friends;
34 use DateTime;
35 use DateTime::Format::ISO8601;
36 use OpenSRF::Utils qw/:datetime/;
37 use Digest::MD5 qw(md5_hex);
38 use OpenSRF::Utils::Cache;
39 use OpenSRF::Utils::JSON;
40 my $apputils = "OpenILS::Application::AppUtils";
41 my $U = $apputils;
42
43 __PACKAGE__->register_method(
44     method    => "test_and_create_hold_batch",
45     api_name  => "open-ils.circ.holds.test_and_create.batch",
46     stream => 1,
47     signature => {
48         desc => q/This is for batch creating a set of holds where every field is identical except for the targets./,
49         params => [
50             { desc => 'Authentication token', type => 'string' },
51             { desc => 'Hash of named parameters.  Same as for open-ils.circ.title_hold.is_possible, though the pertinent target field is automatically populated based on the hold_type and the specified list of targets.', type => 'object'},
52             { desc => 'Array of target ids', type => 'array' }
53         ],
54         return => {
55             desc => 'Array of hold ID on success, -1 on missing arg, event (or ref to array of events) on error(s)',
56         },
57     }
58 );
59
60 __PACKAGE__->register_method(
61     method    => "test_and_create_hold_batch",
62     api_name  => "open-ils.circ.holds.test_and_create.batch.override",
63     stream => 1,
64     signature => {
65         desc  => '@see open-ils.circ.holds.test_and_create.batch',
66     }
67 );
68
69
70 sub test_and_create_hold_batch {
71     my( $self, $conn, $auth, $params, $target_list, $oargs ) = @_;
72
73     my $override = 0;
74     if ($self->api_name =~ /override/) {
75         $override = 1;
76         $oargs = { all => 1 } unless defined $oargs;
77         $$params{oargs} = $oargs; # for is_possible checking.
78     }
79
80     my $e = new_editor(authtoken=>$auth);
81     return $e->die_event unless $e->checkauth;
82     $$params{'requestor'} = $e->requestor->id;
83
84     my $target_field;
85     if ($$params{'hold_type'} eq 'T') { $target_field = 'titleid'; }
86     elsif ($$params{'hold_type'} eq 'C') { $target_field = 'copy_id'; }
87     elsif ($$params{'hold_type'} eq 'R') { $target_field = 'copy_id'; }
88     elsif ($$params{'hold_type'} eq 'F') { $target_field = 'copy_id'; }
89     elsif ($$params{'hold_type'} eq 'I') { $target_field = 'issuanceid'; }
90     elsif ($$params{'hold_type'} eq 'V') { $target_field = 'volume_id'; }
91     elsif ($$params{'hold_type'} eq 'M') { $target_field = 'mrid'; }
92     elsif ($$params{'hold_type'} eq 'P') { $target_field = 'partid'; }
93     else { return undef; }
94
95     my $formats_map = delete $$params{holdable_formats_map};
96
97     foreach (@$target_list) {
98         $$params{$target_field} = $_;
99
100         # copy the requested formats from the target->formats map
101         # into the top-level formats attr for each hold
102         $$params{holdable_formats} = $formats_map->{$_};
103
104         my $res;
105         ($res) = $self->method_lookup(
106             'open-ils.circ.title_hold.is_possible')->run($auth, $params, $override ? $oargs : {});
107         if ($res->{'success'} == 1) {
108
109             $params->{'depth'} = $res->{'depth'} if $res->{'depth'};
110
111             # Remove oargs from params so holds can be created.
112             if ($$params{oargs}) {
113                 delete $$params{oargs};
114             }
115
116             my $ahr = construct_hold_request_object($params);
117             my ($res2) = $self->method_lookup(
118                 $override
119                 ? 'open-ils.circ.holds.create.override'
120                 : 'open-ils.circ.holds.create'
121             )->run($auth, $ahr, $oargs);
122             $res2 = {
123                 'target' => $$params{$target_field},
124                 'result' => $res2
125             };
126             $conn->respond($res2);
127         } else {
128             $res = {
129                 'target' => $$params{$target_field},
130                 'result' => $res
131             };
132             $conn->respond($res);
133         }
134     }
135     return undef;
136 }
137
138 sub construct_hold_request_object {
139     my ($params) = @_;
140
141     my $ahr = Fieldmapper::action::hold_request->new;
142     $ahr->isnew('1');
143
144     foreach my $field (keys %{ $params }) {
145         if ($field eq 'depth') { $ahr->selection_depth($$params{$field}); }
146         elsif ($field eq 'patronid') {
147             $ahr->usr($$params{$field}); }
148         elsif ($field eq 'titleid') { $ahr->target($$params{$field}); }
149         elsif ($field eq 'copy_id') { $ahr->target($$params{$field}); }
150         elsif ($field eq 'issuanceid') { $ahr->target($$params{$field}); }
151         elsif ($field eq 'volume_id') { $ahr->target($$params{$field}); }
152         elsif ($field eq 'mrid') { $ahr->target($$params{$field}); }
153         elsif ($field eq 'partid') { $ahr->target($$params{$field}); }
154         else {
155             $ahr->$field($$params{$field});
156         }
157     }
158     return $ahr;
159 }
160
161 __PACKAGE__->register_method(
162     method    => "create_hold_batch",
163     api_name  => "open-ils.circ.holds.create.batch",
164     stream => 1,
165     signature => {
166         desc => q/@see open-ils.circ.holds.create.batch/,
167         params => [
168             { desc => 'Authentication token', type => 'string' },
169             { desc => 'Array of hold objects', type => 'array' }
170         ],
171         return => {
172             desc => 'Array of hold ID on success, -1 on missing arg, event (or ref to array of events) on error(s)',
173         },
174     }
175 );
176
177 __PACKAGE__->register_method(
178     method    => "create_hold_batch",
179     api_name  => "open-ils.circ.holds.create.override.batch",
180     stream => 1,
181     signature => {
182         desc  => '@see open-ils.circ.holds.create.batch',
183     }
184 );
185
186
187 sub create_hold_batch {
188     my( $self, $conn, $auth, $hold_list, $oargs ) = @_;
189     (my $method = $self->api_name) =~ s/\.batch//og;
190     foreach (@$hold_list) {
191         my ($res) = $self->method_lookup($method)->run($auth, $_, $oargs);
192         $conn->respond($res);
193     }
194     return undef;
195 }
196
197
198 __PACKAGE__->register_method(
199     method    => "create_hold",
200     api_name  => "open-ils.circ.holds.create",
201     signature => {
202         desc => "Create a new hold for an item.  From a permissions perspective, " .
203                 "the login session is used as the 'requestor' of the hold.  "      .
204                 "The hold recipient is determined by the 'usr' setting within the hold object. " .
205                 'First we verify the requestor has holds request permissions.  '         .
206                 'Then we verify that the recipient is allowed to make the given hold.  ' .
207                 'If not, we see if the requestor has "override" capabilities.  If not, ' .
208                 'a permission exception is returned.  If permissions allow, we cycle '   .
209                 'through the set of holds objects and create.  '                         .
210                 'If the recipient does not have permission to place multiple holds '     .
211                 'on a single title and said operation is attempted, a permission '       .
212                 'exception is returned',
213         params => [
214             { desc => 'Authentication token',               type => 'string' },
215             { desc => 'Hold object for hold to be created',
216                 type => 'object', class => 'ahr' }
217         ],
218         return => {
219             desc => 'New ahr ID on success, -1 on missing arg, event (or ref to array of events) on error(s)',
220         },
221     }
222 );
223
224 __PACKAGE__->register_method(
225     method    => "create_hold",
226     api_name  => "open-ils.circ.holds.create.override",
227     notes     => '@see open-ils.circ.holds.create',
228     signature => {
229         desc  => "If the recipient is not allowed to receive the requested hold, " .
230                  "call this method to attempt the override",
231         params => [
232             { desc => 'Authentication token',               type => 'string' },
233             {
234                 desc => 'Hold object for hold to be created',
235                 type => 'object', class => 'ahr'
236             }
237         ],
238         return => {
239             desc => 'New hold (ahr) ID on success, -1 on missing arg, event (or ref to array of events) on error(s)',
240         },
241     }
242 );
243
244 sub create_hold {
245     my( $self, $conn, $auth, $hold, $oargs ) = @_;
246     return -1 unless $hold;
247     my $e = new_editor(authtoken=>$auth, xact=>1);
248     return $e->die_event unless $e->checkauth;
249
250     my $override = 0;
251     if ($self->api_name =~ /override/) {
252         $override = 1;
253         $oargs = { all => 1 } unless defined $oargs;
254     }
255
256     my @events;
257
258     my $requestor = $e->requestor;
259     my $recipient = $requestor;
260
261     if( $requestor->id ne $hold->usr ) {
262         # Make sure the requestor is allowed to place holds for
263         # the recipient if they are not the same people
264         $recipient = $e->retrieve_actor_user($hold->usr)  or return $e->die_event;
265         $e->allowed('REQUEST_HOLDS', $recipient->home_ou) or return $e->die_event;
266     }
267
268     # If the related org setting tells us to, block if patron privs have expired
269     my $expire_setting = $U->ou_ancestor_setting_value($recipient->home_ou, OILS_SETTING_BLOCK_HOLD_FOR_EXPIRED_PATRON);
270     if ($expire_setting) {
271         my $expire = DateTime::Format::ISO8601->new->parse_datetime(
272             cleanse_ISO8601($recipient->expire_date));
273
274         push( @events, OpenILS::Event->new(
275             'PATRON_ACCOUNT_EXPIRED',
276             "payload" => {"fail_part" => "actor.usr.privs_expired"}
277             )) if( CORE::time > $expire->epoch ) ;
278     }
279
280     # Now make sure the recipient is allowed to receive the specified hold
281     my $porg = $recipient->home_ou;
282     my $rid  = $e->requestor->id;
283     my $t    = $hold->hold_type;
284
285     # See if a duplicate hold already exists
286     my $sargs = {
287         usr              => $recipient->id,
288         hold_type        => $t,
289         fulfillment_time => undef,
290         target           => $hold->target,
291         cancel_time      => undef,
292     };
293
294     $sargs->{holdable_formats} = $hold->holdable_formats if $t eq 'M';
295
296     my $existing = $e->search_action_hold_request($sargs);
297     push( @events, OpenILS::Event->new('HOLD_EXISTS')) if @$existing;
298
299     my $checked_out = hold_item_is_checked_out($e, $recipient->id, $hold->hold_type, $hold->target);
300     push( @events, OpenILS::Event->new('HOLD_ITEM_CHECKED_OUT')) if $checked_out;
301
302     if ( $t eq OILS_HOLD_TYPE_METARECORD ) {
303         return $e->die_event unless $e->allowed('MR_HOLDS',     $porg);
304     } elsif ( $t eq OILS_HOLD_TYPE_TITLE ) {
305         return $e->die_event unless $e->allowed('TITLE_HOLDS',  $porg);
306     } elsif ( $t eq OILS_HOLD_TYPE_VOLUME ) {
307         return $e->die_event unless $e->allowed('VOLUME_HOLDS', $porg);
308     } elsif ( $t eq OILS_HOLD_TYPE_MONOPART ) {
309         return $e->die_event unless $e->allowed('TITLE_HOLDS', $porg);
310     } elsif ( $t eq OILS_HOLD_TYPE_ISSUANCE ) {
311         return $e->die_event unless $e->allowed('ISSUANCE_HOLDS', $porg);
312     } elsif ( $t eq OILS_HOLD_TYPE_COPY ) {
313         return $e->die_event unless $e->allowed('COPY_HOLDS',   $porg);
314     } elsif ( $t eq OILS_HOLD_TYPE_FORCE || $t eq OILS_HOLD_TYPE_RECALL ) {
315         my $copy = $e->retrieve_asset_copy($hold->target)
316             or return $e->die_event;
317         if ( $t eq OILS_HOLD_TYPE_FORCE ) {
318             return $e->die_event unless $e->allowed('COPY_HOLDS_FORCE',   $copy->circ_lib);
319         } elsif ( $t eq OILS_HOLD_TYPE_RECALL ) {
320             return $e->die_event unless $e->allowed('COPY_HOLDS_RECALL',   $copy->circ_lib);
321         }
322     }
323
324     if( @events ) {
325         if (!$override) {
326             $e->rollback;
327             return \@events;
328         }
329         for my $evt (@events) {
330             next unless $evt;
331             my $name = $evt->{textcode};
332             if($oargs->{all} || grep { $_ eq $name } @{$oargs->{events}}) {
333                 return $e->die_event unless $e->allowed("$name.override", $porg);
334             } else {
335                 $e->rollback;
336                 return \@events;
337             }
338         }
339     }
340
341         # Check for hold expiration in the past, and set it to empty string.
342         $hold->expire_time(undef) if ($hold->expire_time && $U->datecmp($hold->expire_time) == -1);
343
344     # set the configured expire time
345     unless($hold->expire_time) {
346         $hold->expire_time(calculate_expire_time($recipient->home_ou));
347     }
348
349
350     # if behind-the-desk pickup is supported at the hold pickup lib,
351     # set the value to the patron default, unless a value has already
352     # been applied.  If it's not supported, force the value to false.
353
354     my $bdous = $U->ou_ancestor_setting_value(
355         $hold->pickup_lib, 
356         'circ.holds.behind_desk_pickup_supported', $e);
357
358     if ($bdous) {
359         if (!defined $hold->behind_desk) {
360
361             my $set = $e->search_actor_user_setting({
362                 usr => $hold->usr, 
363                 name => 'circ.holds_behind_desk'
364             })->[0];
365         
366             $hold->behind_desk('t') if $set and 
367                 OpenSRF::Utils::JSON->JSON2perl($set->value);
368         }
369     } else {
370         # behind the desk not supported, force it to false
371         $hold->behind_desk('f');
372     }
373
374     $hold->requestor($e->requestor->id);
375     $hold->request_lib($e->requestor->ws_ou);
376     $hold->selection_ou($hold->pickup_lib) unless $hold->selection_ou;
377     $hold = $e->create_action_hold_request($hold) or return $e->die_event;
378
379     $e->commit;
380
381     $conn->respond_complete($hold->id);
382
383     $U->storagereq(
384         'open-ils.storage.action.hold_request.copy_targeter',
385         undef, $hold->id ) unless $U->is_true($hold->frozen);
386
387     return undef;
388 }
389
390 # makes sure that a user has permission to place the type of requested hold
391 # returns the Perm exception if not allowed, returns undef if all is well
392 sub _check_holds_perm {
393     my($type, $user_id, $org_id) = @_;
394
395     my $evt;
396     if ($type eq "M") {
397         $evt = $apputils->check_perms($user_id, $org_id, "MR_HOLDS"    );
398     } elsif ($type eq "T") {
399         $evt = $apputils->check_perms($user_id, $org_id, "TITLE_HOLDS" );
400     } elsif($type eq "V") {
401         $evt = $apputils->check_perms($user_id, $org_id, "VOLUME_HOLDS");
402     } elsif($type eq "C") {
403         $evt = $apputils->check_perms($user_id, $org_id, "COPY_HOLDS"  );
404     }
405
406     return $evt if $evt;
407     return undef;
408 }
409
410 # tests if the given user is allowed to place holds on another's behalf
411 sub _check_request_holds_perm {
412     my $user_id = shift;
413     my $org_id  = shift;
414     if (my $evt = $apputils->check_perms(
415         $user_id, $org_id, "REQUEST_HOLDS")) {
416         return $evt;
417     }
418 }
419
420 my $ses_is_req_note = 'The login session is the requestor.  If the requestor is different from the user, ' .
421                       'then the requestor must have VIEW_HOLD permissions';
422
423 __PACKAGE__->register_method(
424     method    => "retrieve_holds_by_id",
425     api_name  => "open-ils.circ.holds.retrieve_by_id",
426     signature => {
427         desc   => "Retrieve the hold, with hold transits attached, for the specified ID.  $ses_is_req_note",
428         params => [
429             { desc => 'Authentication token', type => 'string' },
430             { desc => 'Hold ID',              type => 'number' }
431         ],
432         return => {
433             desc => 'Hold object with transits attached, event on error',
434         }
435     }
436 );
437
438
439 sub retrieve_holds_by_id {
440     my($self, $client, $auth, $hold_id) = @_;
441     my $e = new_editor(authtoken=>$auth);
442     $e->checkauth or return $e->event;
443     $e->allowed('VIEW_HOLD') or return $e->event;
444
445     my $holds = $e->search_action_hold_request(
446         [
447             { id =>  $hold_id , fulfillment_time => undef },
448             {
449                 order_by => { ahr => "request_time" },
450                 flesh => 1,
451                 flesh_fields => {ahr => ['notes']}
452             }
453         ]
454     );
455
456     flesh_hold_transits($holds);
457     flesh_hold_notices($holds, $e);
458     return $holds;
459 }
460
461
462 __PACKAGE__->register_method(
463     method    => "retrieve_holds",
464     api_name  => "open-ils.circ.holds.retrieve",
465     signature => {
466         desc   => "Retrieves all the holds, with hold transits attached, for the specified user.  $ses_is_req_note",
467         params => [
468             { desc => 'Authentication token', type => 'string'  },
469             { desc => 'User ID',              type => 'integer' },
470             { desc => 'Available Only',       type => 'boolean' }
471         ],
472         return => {
473             desc => 'list of holds, event on error',
474         }
475    }
476 );
477
478 __PACKAGE__->register_method(
479     method        => "retrieve_holds",
480     api_name      => "open-ils.circ.holds.id_list.retrieve",
481     authoritative => 1,
482     signature     => {
483         desc   => "Retrieves all the hold IDs, for the specified user.  $ses_is_req_note",
484         params => [
485             { desc => 'Authentication token', type => 'string'  },
486             { desc => 'User ID',              type => 'integer' },
487             { desc => 'Available Only',       type => 'boolean' }
488         ],
489         return => {
490             desc => 'list of holds, event on error',
491         }
492    }
493 );
494
495 __PACKAGE__->register_method(
496     method        => "retrieve_holds",
497     api_name      => "open-ils.circ.holds.canceled.retrieve",
498     authoritative => 1,
499     signature     => {
500         desc   => "Retrieves all the cancelled holds for the specified user.  $ses_is_req_note",
501         params => [
502             { desc => 'Authentication token', type => 'string'  },
503             { desc => 'User ID',              type => 'integer' }
504         ],
505         return => {
506             desc => 'list of holds, event on error',
507         }
508    }
509 );
510
511 __PACKAGE__->register_method(
512     method        => "retrieve_holds",
513     api_name      => "open-ils.circ.holds.canceled.id_list.retrieve",
514     authoritative => 1,
515     signature     => {
516         desc   => "Retrieves list of cancelled hold IDs for the specified user.  $ses_is_req_note",
517         params => [
518             { desc => 'Authentication token', type => 'string'  },
519             { desc => 'User ID',              type => 'integer' }
520         ],
521         return => {
522             desc => 'list of hold IDs, event on error',
523         }
524    }
525 );
526
527
528 sub retrieve_holds {
529     my ($self, $client, $auth, $user_id, $available) = @_;
530
531     my $e = new_editor(authtoken=>$auth);
532     return $e->event unless $e->checkauth;
533     $user_id = $e->requestor->id unless defined $user_id;
534
535     my $notes_filter = {staff => 'f'};
536     my $user = $e->retrieve_actor_user($user_id) or return $e->event;
537     unless($user_id == $e->requestor->id) {
538         if($e->allowed('VIEW_HOLD', $user->home_ou)) {
539             $notes_filter = {staff => 't'}
540         } else {
541             my $allowed = OpenILS::Application::Actor::Friends->friend_perm_allowed(
542                 $e, $user_id, $e->requestor->id, 'hold.view');
543             return $e->event unless $allowed;
544         }
545     } else {
546         # staff member looking at his/her own holds can see staff and non-staff notes
547         $notes_filter = {} if $e->allowed('VIEW_HOLD', $user->home_ou);
548     }
549
550     my $holds_query = {
551         select => {ahr => ['id']},
552         from => 'ahr',
553         where => {usr => $user_id, fulfillment_time => undef}
554     };
555
556     if($self->api_name =~ /canceled/) {
557
558         # Fetch the canceled holds
559         # order cancelled holds by cancel time, most recent first
560
561         $holds_query->{order_by} = [{class => 'ahr', field => 'cancel_time', direction => 'desc'}];
562
563         my $cancel_age;
564         my $cancel_count = $U->ou_ancestor_setting_value(
565                 $e->requestor->ws_ou, 'circ.holds.canceled.display_count', $e);
566
567         unless($cancel_count) {
568             $cancel_age = $U->ou_ancestor_setting_value(
569                 $e->requestor->ws_ou, 'circ.holds.canceled.display_age', $e);
570
571             # if no settings are defined, default to last 10 cancelled holds
572             $cancel_count = 10 unless $cancel_age;
573         }
574
575         if($cancel_count) { # limit by count
576
577             $holds_query->{where}->{cancel_time} = {'!=' => undef};
578             $holds_query->{limit} = $cancel_count;
579
580         } elsif($cancel_age) { # limit by age
581
582             # find all of the canceled holds that were canceled within the configured time frame
583             my $date = DateTime->now->subtract(seconds => OpenSRF::Utils::interval_to_seconds($cancel_age));
584             $date = $U->epoch2ISO8601($date->epoch);
585             $holds_query->{where}->{cancel_time} = {'>=' => $date};
586         }
587
588     } else {
589
590         # order non-cancelled holds by ready-for-pickup, then active, followed by suspended
591         # "compare" sorts false values to the front.  testing pickup_lib != current_shelf_lib
592         # will sort by pl = csl > pl != csl > followed by csl is null;
593         $holds_query->{order_by} = [
594             {   class => 'ahr',
595                 field => 'pickup_lib',
596                 compare => {'!='  => {'+ahr' => 'current_shelf_lib'}}},
597             {class => 'ahr', field => 'shelf_time'},
598             {class => 'ahr', field => 'frozen'},
599             {class => 'ahr', field => 'request_time'}
600
601         ];
602         $holds_query->{where}->{cancel_time} = undef;
603         if($available) {
604             $holds_query->{where}->{shelf_time} = {'!=' => undef};
605             # Maybe?
606             $holds_query->{where}->{pickup_lib} = {'=' => {'+ahr' => 'current_shelf_lib'}};
607         }
608     }
609
610     my $hold_ids = $e->json_query($holds_query);
611     $hold_ids = [ map { $_->{id} } @$hold_ids ];
612
613     return $hold_ids if $self->api_name =~ /id_list/;
614
615     my @holds;
616     for my $hold_id ( @$hold_ids ) {
617
618         my $hold = $e->retrieve_action_hold_request($hold_id);
619         $hold->notes($e->search_action_hold_request_note({hold => $hold_id, %$notes_filter}));
620
621         $hold->transit(
622             $e->search_action_hold_transit_copy([
623                 {hold => $hold->id},
624                 {order_by => {ahtc => 'source_send_time desc'}, limit => 1}])->[0]
625         );
626
627         push(@holds, $hold);
628     }
629
630     return \@holds;
631 }
632
633
634 __PACKAGE__->register_method(
635     method   => 'user_hold_count',
636     api_name => 'open-ils.circ.hold.user.count'
637 );
638
639 sub user_hold_count {
640     my ( $self, $conn, $auth, $userid ) = @_;
641     my $e = new_editor( authtoken => $auth );
642     return $e->event unless $e->checkauth;
643     my $patron = $e->retrieve_actor_user($userid)
644         or return $e->event;
645     return $e->event unless $e->allowed( 'VIEW_HOLD', $patron->home_ou );
646     return __user_hold_count( $self, $e, $userid );
647 }
648
649 sub __user_hold_count {
650     my ( $self, $e, $userid ) = @_;
651     my $holds = $e->search_action_hold_request(
652         {
653             usr              => $userid,
654             fulfillment_time => undef,
655             cancel_time      => undef,
656         },
657         { idlist => 1 }
658     );
659
660     return scalar(@$holds);
661 }
662
663
664 __PACKAGE__->register_method(
665     method   => "retrieve_holds_by_pickup_lib",
666     api_name => "open-ils.circ.holds.retrieve_by_pickup_lib",
667     notes    =>
668       "Retrieves all the holds, with hold transits attached, for the specified pickup_ou id."
669 );
670
671 __PACKAGE__->register_method(
672     method   => "retrieve_holds_by_pickup_lib",
673     api_name => "open-ils.circ.holds.id_list.retrieve_by_pickup_lib",
674     notes    => "Retrieves all the hold ids for the specified pickup_ou id. "
675 );
676
677 sub retrieve_holds_by_pickup_lib {
678     my ($self, $client, $login_session, $ou_id) = @_;
679
680     #FIXME -- put an appropriate permission check here
681     #my( $user, $target, $evt ) = $apputils->checkses_requestor(
682     #    $login_session, $user_id, 'VIEW_HOLD' );
683     #return $evt if $evt;
684
685     my $holds = $apputils->simplereq(
686         'open-ils.cstore',
687         "open-ils.cstore.direct.action.hold_request.search.atomic",
688         {
689             pickup_lib =>  $ou_id ,
690             fulfillment_time => undef,
691             cancel_time => undef
692         },
693         { order_by => { ahr => "request_time" } }
694     );
695
696     if ( ! $self->api_name =~ /id_list/ ) {
697         flesh_hold_transits($holds);
698         return $holds;
699     }
700     # else id_list
701     return [ map { $_->id } @$holds ];
702 }
703
704
705 __PACKAGE__->register_method(
706     method   => "uncancel_hold",
707     api_name => "open-ils.circ.hold.uncancel"
708 );
709
710 sub uncancel_hold {
711     my($self, $client, $auth, $hold_id) = @_;
712     my $e = new_editor(authtoken=>$auth, xact=>1);
713     return $e->die_event unless $e->checkauth;
714
715     my $hold = $e->retrieve_action_hold_request($hold_id)
716         or return $e->die_event;
717     return $e->die_event unless $e->allowed('CANCEL_HOLDS', $hold->request_lib);
718
719     if ($hold->fulfillment_time) {
720         $e->rollback;
721         return 0;
722     }
723     unless ($hold->cancel_time) {
724         $e->rollback;
725         return 1;
726     }
727
728     # if configured to reset the request time, also reset the expire time
729     if($U->ou_ancestor_setting_value(
730         $hold->request_lib, 'circ.holds.uncancel.reset_request_time', $e)) {
731
732         $hold->request_time('now');
733         $hold->expire_time(calculate_expire_time($hold->request_lib));
734     }
735
736     $hold->clear_cancel_time;
737     $hold->clear_cancel_cause;
738     $hold->clear_cancel_note;
739     $hold->clear_shelf_time;
740     $hold->clear_current_copy;
741     $hold->clear_capture_time;
742     $hold->clear_prev_check_time;
743     $hold->clear_shelf_expire_time;
744     $hold->clear_current_shelf_lib;
745
746     $e->update_action_hold_request($hold) or return $e->die_event;
747     $e->commit;
748
749     $U->storagereq('open-ils.storage.action.hold_request.copy_targeter', undef, $hold_id);
750
751     return 1;
752 }
753
754
755 __PACKAGE__->register_method(
756     method    => "cancel_hold",
757     api_name  => "open-ils.circ.hold.cancel",
758     signature => {
759         desc   => 'Cancels the specified hold.  The login session is the requestor.  If the requestor is different from the usr field ' .
760                   'on the hold, the requestor must have CANCEL_HOLDS permissions. The hold may be either the hold object or the hold id',
761         param  => [
762             {desc => 'Authentication token',  type => 'string'},
763             {desc => 'Hold ID',               type => 'number'},
764             {desc => 'Cause of Cancellation', type => 'string'},
765             {desc => 'Note',                  type => 'string'}
766         ],
767         return => {
768             desc => '1 on success, event on error'
769         }
770     }
771 );
772
773 sub cancel_hold {
774     my($self, $client, $auth, $holdid, $cause, $note) = @_;
775
776     my $e = new_editor(authtoken=>$auth, xact=>1);
777     return $e->die_event unless $e->checkauth;
778
779     my $hold = $e->retrieve_action_hold_request($holdid)
780         or return $e->die_event;
781
782     if( $e->requestor->id ne $hold->usr ) {
783         return $e->die_event unless $e->allowed('CANCEL_HOLDS');
784     }
785
786     if ($hold->cancel_time) {
787         $e->rollback;
788         return 1;
789     }
790
791     # If the hold is captured, reset the copy status
792     if( $hold->capture_time and $hold->current_copy ) {
793
794         my $copy = $e->retrieve_asset_copy($hold->current_copy)
795             or return $e->die_event;
796
797         if( $copy->status == OILS_COPY_STATUS_ON_HOLDS_SHELF ) {
798          $logger->info("canceling hold $holdid whose item is on the holds shelf");
799 #            $logger->info("setting copy to status 'reshelving' on hold cancel");
800 #            $copy->status(OILS_COPY_STATUS_RESHELVING);
801 #            $copy->editor($e->requestor->id);
802 #            $copy->edit_date('now');
803 #            $e->update_asset_copy($copy) or return $e->event;
804
805         } elsif( $copy->status == OILS_COPY_STATUS_IN_TRANSIT ) {
806
807             my $hid = $hold->id;
808             $logger->warn("! canceling hold [$hid] that is in transit");
809             my $transid = $e->search_action_hold_transit_copy({hold=>$hold->id},{idlist=>1})->[0];
810
811             if( $transid ) {
812                 my $trans = $e->retrieve_action_transit_copy($transid);
813                 # Leave the transit alive, but  set the copy status to
814                 # reshelving so it will be properly reshelved when it gets back home
815                 if( $trans ) {
816                     $trans->copy_status( OILS_COPY_STATUS_RESHELVING );
817                     $e->update_action_transit_copy($trans) or return $e->die_event;
818                 }
819             }
820         }
821     }
822
823     $hold->cancel_time('now');
824     $hold->cancel_cause($cause);
825     $hold->cancel_note($note);
826     $e->update_action_hold_request($hold)
827         or return $e->die_event;
828
829     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.filterd_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     
2502     my $org_at_depth = defined($d) ? $U->org_unit_ancestor_at_depth($o, $d) : $o;
2503     return $U->storagereq('open-ils.storage.metarecord.filtered_records.atomic', $m, $f, $org_at_depth);
2504 }
2505 __PACKAGE__->register_method(
2506     method   => 'MR_filter_records',
2507     api_name => 'open-ils.circ.holds.metarecord.filterd_records',
2508 );
2509
2510
2511 my %prox_cache;
2512 sub create_ranged_org_filter {
2513     my($e, $selection_ou, $depth) = @_;
2514
2515     # find the orgs from which this hold may be fulfilled,
2516     # based on the selection_ou and depth
2517
2518     my $top_org = $e->search_actor_org_unit([
2519         {parent_ou => undef},
2520         {flesh=>1, flesh_fields=>{aou=>['ou_type']}}])->[0];
2521     my %org_filter;
2522
2523     return () if $depth == $top_org->ou_type->depth;
2524
2525     my $org_list = $U->storagereq('open-ils.storage.actor.org_unit.descendants.atomic', $selection_ou, $depth);
2526     %org_filter = (circ_lib => []);
2527     push(@{$org_filter{circ_lib}}, $_->id) for @$org_list;
2528
2529     $logger->info("hold org filter at depth $depth and selection_ou ".
2530         "$selection_ou created list of @{$org_filter{circ_lib}}");
2531
2532     return %org_filter;
2533 }
2534
2535
2536 sub _check_title_hold_is_possible {
2537     my( $titleid, $depth, $request_lib, $patron, $requestor, $pickup_lib, $selection_ou, $holdable_formats, $oargs ) = @_;
2538     # $holdable_formats is now unused. We pre-filter the MR's records.
2539
2540     my $e = new_editor();
2541     my %org_filter = create_ranged_org_filter($e, $selection_ou, $depth);
2542
2543     # this monster will grab the id and circ_lib of all of the "holdable" copies for the given record
2544     my $copies = $e->json_query(
2545         {
2546             select => { acp => ['id', 'circ_lib'] },
2547               from => {
2548                 acp => {
2549                     acn => {
2550                         field  => 'id',
2551                         fkey   => 'call_number',
2552                         filter => { record => $titleid }
2553                     },
2554                     acpl => { field => 'id', filter => { holdable => 't'}, fkey => 'location' },
2555                     ccs  => { field => 'id', filter => { holdable => 't'}, fkey => 'status'   },
2556                     acpm => { field => 'target_copy', type => 'left' } # ignore part-linked copies
2557                 }
2558             },
2559             where => {
2560                 '+acp' => { circulate => 't', deleted => 'f', holdable => 't', %org_filter },
2561                 '+acpm' => { target_copy => undef } # ignore part-linked copies
2562             }
2563         }
2564     );
2565
2566     $logger->info("title possible found ".scalar(@$copies)." potential copies");
2567     return (
2568         0, 0, [
2569             new OpenILS::Event(
2570                 "HIGH_LEVEL_HOLD_HAS_NO_COPIES",
2571                 "payload" => {"fail_part" => "no_ultimate_items"}
2572             )
2573         ]
2574     ) unless @$copies;
2575
2576     # -----------------------------------------------------------------------
2577     # sort the copies into buckets based on their circ_lib proximity to
2578     # the patron's home_ou.
2579     # -----------------------------------------------------------------------
2580
2581     my $home_org = $patron->home_ou;
2582     my $req_org = $request_lib->id;
2583
2584     $logger->info("prox cache $home_org " . $prox_cache{$home_org});
2585
2586     $prox_cache{$home_org} =
2587         $e->search_actor_org_unit_proximity({from_org => $home_org})
2588         unless $prox_cache{$home_org};
2589     my $home_prox = $prox_cache{$home_org};
2590
2591     my %buckets;
2592     my %hash = map { ($_->to_org => $_->prox) } @$home_prox;
2593     push( @{$buckets{ $hash{$_->{circ_lib}} } }, $_->{id} ) for @$copies;
2594
2595     my @keys = sort { $a <=> $b } keys %buckets;
2596
2597
2598     if( $home_org ne $req_org ) {
2599       # -----------------------------------------------------------------------
2600       # shove the copies close to the request_lib into the primary buckets
2601       # directly before the farthest away copies.  That way, they are not
2602       # given priority, but they are checked before the farthest copies.
2603       # -----------------------------------------------------------------------
2604         $prox_cache{$req_org} =
2605             $e->search_actor_org_unit_proximity({from_org => $req_org})
2606             unless $prox_cache{$req_org};
2607         my $req_prox = $prox_cache{$req_org};
2608
2609         my %buckets2;
2610         my %hash2 = map { ($_->to_org => $_->prox) } @$req_prox;
2611         push( @{$buckets2{ $hash2{$_->{circ_lib}} } }, $_->{id} ) for @$copies;
2612
2613         my $highest_key = $keys[@keys - 1];  # the farthest prox in the exising buckets
2614         my $new_key = $highest_key - 0.5; # right before the farthest prox
2615         my @keys2   = sort { $a <=> $b } keys %buckets2;
2616         for my $key (@keys2) {
2617             last if $key >= $highest_key;
2618             push( @{$buckets{$new_key}}, $_ ) for @{$buckets2{$key}};
2619         }
2620     }
2621
2622     @keys = sort { $a <=> $b } keys %buckets;
2623
2624     my $title;
2625     my %seen;
2626     my @status;
2627     my $age_protect_only = 0;
2628     OUTER: for my $key (@keys) {
2629       my @cps = @{$buckets{$key}};
2630
2631       $logger->info("looking at " . scalar(@{$buckets{$key}}). " copies in proximity bucket $key");
2632
2633       for my $copyid (@cps) {
2634
2635          next if $seen{$copyid};
2636          $seen{$copyid} = 1; # there could be dupes given the merged buckets
2637          my $copy = $e->retrieve_asset_copy($copyid);
2638          $logger->debug("looking at bucket_key=$key, copy $copyid : circ_lib = " . $copy->circ_lib);
2639
2640          unless($title) { # grab the title if we don't already have it
2641             my $vol = $e->retrieve_asset_call_number(
2642                [ $copy->call_number, { flesh => 1, flesh_fields => { bre => ['fixed_fields'], acn => ['record'] } } ] );
2643             $title = $vol->record;
2644          }
2645
2646          @status = verify_copy_for_hold(
2647             $patron, $requestor, $title, $copy, $pickup_lib, $request_lib, $oargs);
2648
2649          $age_protect_only ||= $status[3];
2650          last OUTER if $status[0];
2651       }
2652     }
2653
2654     $status[3] = $age_protect_only;
2655     return @status;
2656 }
2657
2658 sub _check_issuance_hold_is_possible {
2659     my( $issuanceid, $depth, $request_lib, $patron, $requestor, $pickup_lib, $selection_ou, $oargs ) = @_;
2660
2661     my $e = new_editor();
2662     my %org_filter = create_ranged_org_filter($e, $selection_ou, $depth);
2663
2664     # this monster will grab the id and circ_lib of all of the "holdable" copies for the given record
2665     my $copies = $e->json_query(
2666         {
2667             select => { acp => ['id', 'circ_lib'] },
2668               from => {
2669                 acp => {
2670                     sitem => {
2671                         field  => 'unit',
2672                         fkey   => 'id',
2673                         filter => { issuance => $issuanceid }
2674                     },
2675                     acpl => { field => 'id', filter => { holdable => 't'}, fkey => 'location' },
2676                     ccs  => { field => 'id', filter => { holdable => 't'}, fkey => 'status'   }
2677                 }
2678             },
2679             where => {
2680                 '+acp' => { circulate => 't', deleted => 'f', holdable => 't', %org_filter }
2681             },
2682             distinct => 1
2683         }
2684     );
2685
2686     $logger->info("issuance possible found ".scalar(@$copies)." potential copies");
2687
2688     my $empty_ok;
2689     if (!@$copies) {
2690         $empty_ok = $e->retrieve_config_global_flag('circ.holds.empty_issuance_ok');
2691         $empty_ok = ($empty_ok and $U->is_true($empty_ok->enabled));
2692
2693         return (
2694             0, 0, [
2695                 new OpenILS::Event(
2696                     "HIGH_LEVEL_HOLD_HAS_NO_COPIES",
2697                     "payload" => {"fail_part" => "no_ultimate_items"}
2698                 )
2699             ]
2700         ) unless $empty_ok;
2701
2702         return (1, 0);
2703     }
2704
2705     # -----------------------------------------------------------------------
2706     # sort the copies into buckets based on their circ_lib proximity to
2707     # the patron's home_ou.
2708     # -----------------------------------------------------------------------
2709
2710     my $home_org = $patron->home_ou;
2711     my $req_org = $request_lib->id;
2712
2713     $logger->info("prox cache $home_org " . $prox_cache{$home_org});
2714
2715     $prox_cache{$home_org} =
2716         $e->search_actor_org_unit_proximity({from_org => $home_org})
2717         unless $prox_cache{$home_org};
2718     my $home_prox = $prox_cache{$home_org};
2719
2720     my %buckets;
2721     my %hash = map { ($_->to_org => $_->prox) } @$home_prox;
2722     push( @{$buckets{ $hash{$_->{circ_lib}} } }, $_->{id} ) for @$copies;
2723
2724     my @keys = sort { $a <=> $b } keys %buckets;
2725
2726
2727     if( $home_org ne $req_org ) {
2728       # -----------------------------------------------------------------------
2729       # shove the copies close to the request_lib into the primary buckets
2730       # directly before the farthest away copies.  That way, they are not
2731       # given priority, but they are checked before the farthest copies.
2732       # -----------------------------------------------------------------------
2733         $prox_cache{$req_org} =
2734             $e->search_actor_org_unit_proximity({from_org => $req_org})
2735             unless $prox_cache{$req_org};
2736         my $req_prox = $prox_cache{$req_org};
2737
2738         my %buckets2;
2739         my %hash2 = map { ($_->to_org => $_->prox) } @$req_prox;
2740         push( @{$buckets2{ $hash2{$_->{circ_lib}} } }, $_->{id} ) for @$copies;
2741
2742         my $highest_key = $keys[@keys - 1];  # the farthest prox in the exising buckets
2743         my $new_key = $highest_key - 0.5; # right before the farthest prox
2744         my @keys2   = sort { $a <=> $b } keys %buckets2;
2745         for my $key (@keys2) {
2746             last if $key >= $highest_key;
2747             push( @{$buckets{$new_key}}, $_ ) for @{$buckets2{$key}};
2748         }
2749     }
2750
2751     @keys = sort { $a <=> $b } keys %buckets;
2752
2753     my $title;
2754     my %seen;
2755     my @status;
2756     my $age_protect_only = 0;
2757     OUTER: for my $key (@keys) {
2758       my @cps = @{$buckets{$key}};
2759
2760       $logger->info("looking at " . scalar(@{$buckets{$key}}). " copies in proximity bucket $key");
2761
2762       for my $copyid (@cps) {
2763
2764          next if $seen{$copyid};
2765          $seen{$copyid} = 1; # there could be dupes given the merged buckets
2766          my $copy = $e->retrieve_asset_copy($copyid);
2767          $logger->debug("looking at bucket_key=$key, copy $copyid : circ_lib = " . $copy->circ_lib);
2768
2769          unless($title) { # grab the title if we don't already have it
2770             my $vol = $e->retrieve_asset_call_number(
2771                [ $copy->call_number, { flesh => 1, flesh_fields => { bre => ['fixed_fields'], acn => ['record'] } } ] );
2772             $title = $vol->record;
2773          }
2774
2775          @status = verify_copy_for_hold(
2776             $patron, $requestor, $title, $copy, $pickup_lib, $request_lib, $oargs);
2777
2778          $age_protect_only ||= $status[3];
2779          last OUTER if $status[0];
2780       }
2781     }
2782
2783     if (!$status[0]) {
2784         if (!defined($empty_ok)) {
2785             $empty_ok = $e->retrieve_config_global_flag('circ.holds.empty_issuance_ok');
2786             $empty_ok = ($empty_ok and $U->is_true($empty_ok->enabled));
2787         }
2788
2789         return (1,0) if ($empty_ok);
2790     }
2791     $status[3] = $age_protect_only;
2792     return @status;
2793 }
2794
2795 sub _check_monopart_hold_is_possible {
2796     my( $partid, $depth, $request_lib, $patron, $requestor, $pickup_lib, $selection_ou, $oargs ) = @_;
2797
2798     my $e = new_editor();
2799     my %org_filter = create_ranged_org_filter($e, $selection_ou, $depth);
2800
2801     # this monster will grab the id and circ_lib of all of the "holdable" copies for the given record
2802     my $copies = $e->json_query(
2803         {
2804             select => { acp => ['id', 'circ_lib'] },
2805               from => {
2806                 acp => {
2807                     acpm => {
2808                         field  => 'target_copy',
2809                         fkey   => 'id',
2810                         filter => { part => $partid }
2811                     },
2812                     acpl => { field => 'id', filter => { holdable => 't'}, fkey => 'location' },
2813                     ccs  => { field => 'id', filter => { holdable => 't'}, fkey => 'status'   }
2814                 }
2815             },
2816             where => {
2817                 '+acp' => { circulate => 't', deleted => 'f', holdable => 't', %org_filter }
2818             },
2819             distinct => 1
2820         }
2821     );
2822
2823     $logger->info("monopart possible found ".scalar(@$copies)." potential copies");
2824
2825     my $empty_ok;
2826     if (!@$copies) {
2827         $empty_ok = $e->retrieve_config_global_flag('circ.holds.empty_part_ok');
2828         $empty_ok = ($empty_ok and $U->is_true($empty_ok->enabled));
2829
2830         return (
2831             0, 0, [
2832                 new OpenILS::Event(
2833                     "HIGH_LEVEL_HOLD_HAS_NO_COPIES",
2834                     "payload" => {"fail_part" => "no_ultimate_items"}
2835                 )
2836             ]
2837         ) unless $empty_ok;
2838
2839         return (1, 0);
2840     }
2841
2842     # -----------------------------------------------------------------------
2843     # sort the copies into buckets based on their circ_lib proximity to
2844     # the patron's home_ou.
2845     # -----------------------------------------------------------------------
2846
2847     my $home_org = $patron->home_ou;
2848     my $req_org = $request_lib->id;
2849
2850     $logger->info("prox cache $home_org " . $prox_cache{$home_org});
2851
2852     $prox_cache{$home_org} =
2853         $e->search_actor_org_unit_proximity({from_org => $home_org})
2854         unless $prox_cache{$home_org};
2855     my $home_prox = $prox_cache{$home_org};
2856
2857     my %buckets;
2858     my %hash = map { ($_->to_org => $_->prox) } @$home_prox;
2859     push( @{$buckets{ $hash{$_->{circ_lib}} } }, $_->{id} ) for @$copies;
2860
2861     my @keys = sort { $a <=> $b } keys %buckets;
2862
2863
2864     if( $home_org ne $req_org ) {
2865       # -----------------------------------------------------------------------
2866       # shove the copies close to the request_lib into the primary buckets
2867       # directly before the farthest away copies.  That way, they are not
2868       # given priority, but they are checked before the farthest copies.
2869       # -----------------------------------------------------------------------
2870         $prox_cache{$req_org} =
2871             $e->search_actor_org_unit_proximity({from_org => $req_org})
2872             unless $prox_cache{$req_org};
2873         my $req_prox = $prox_cache{$req_org};
2874
2875         my %buckets2;
2876         my %hash2 = map { ($_->to_org => $_->prox) } @$req_prox;
2877         push( @{$buckets2{ $hash2{$_->{circ_lib}} } }, $_->{id} ) for @$copies;
2878
2879         my $highest_key = $keys[@keys - 1];  # the farthest prox in the exising buckets
2880         my $new_key = $highest_key - 0.5; # right before the farthest prox
2881         my @keys2   = sort { $a <=> $b } keys %buckets2;
2882         for my $key (@keys2) {
2883             last if $key >= $highest_key;
2884             push( @{$buckets{$new_key}}, $_ ) for @{$buckets2{$key}};
2885         }
2886     }
2887
2888     @keys = sort { $a <=> $b } keys %buckets;
2889
2890     my $title;
2891     my %seen;
2892     my @status;
2893     my $age_protect_only = 0;
2894     OUTER: for my $key (@keys) {
2895       my @cps = @{$buckets{$key}};
2896
2897       $logger->info("looking at " . scalar(@{$buckets{$key}}). " copies in proximity bucket $key");
2898
2899       for my $copyid (@cps) {
2900
2901          next if $seen{$copyid};
2902          $seen{$copyid} = 1; # there could be dupes given the merged buckets
2903          my $copy = $e->retrieve_asset_copy($copyid);
2904          $logger->debug("looking at bucket_key=$key, copy $copyid : circ_lib = " . $copy->circ_lib);
2905
2906          unless($title) { # grab the title if we don't already have it
2907             my $vol = $e->retrieve_asset_call_number(
2908                [ $copy->call_number, { flesh => 1, flesh_fields => { bre => ['fixed_fields'], acn => ['record'] } } ] );
2909             $title = $vol->record;
2910          }
2911
2912          @status = verify_copy_for_hold(
2913             $patron, $requestor, $title, $copy, $pickup_lib, $request_lib, $oargs);
2914
2915          $age_protect_only ||= $status[3];
2916          last OUTER if $status[0];
2917       }
2918     }
2919
2920     if (!$status[0]) {
2921         if (!defined($empty_ok)) {
2922             $empty_ok = $e->retrieve_config_global_flag('circ.holds.empty_part_ok');
2923             $empty_ok = ($empty_ok and $U->is_true($empty_ok->enabled));
2924         }
2925
2926         return (1,0) if ($empty_ok);
2927     }
2928     $status[3] = $age_protect_only;
2929     return @status;
2930 }
2931
2932
2933 sub _check_volume_hold_is_possible {
2934     my( $vol, $title, $depth, $request_lib, $patron, $requestor, $pickup_lib, $selection_ou, $oargs ) = @_;
2935     my %org_filter = create_ranged_org_filter(new_editor(), $selection_ou, $depth);
2936     my $copies = new_editor->search_asset_copy({call_number => $vol->id, %org_filter});
2937     $logger->info("checking possibility of volume hold for volume ".$vol->id);
2938
2939     my $filter_copies = [];
2940     for my $copy (@$copies) {
2941         # ignore part-mapped copies for regular volume level holds
2942         push(@$filter_copies, $copy) unless
2943             new_editor->search_asset_copy_part_map({target_copy => $copy->id})->[0];
2944     }
2945     $copies = $filter_copies;
2946
2947     return (
2948         0, 0, [
2949             new OpenILS::Event(
2950                 "HIGH_LEVEL_HOLD_HAS_NO_COPIES",
2951                 "payload" => {"fail_part" => "no_ultimate_items"}
2952             )
2953         ]
2954     ) unless @$copies;
2955
2956     my @status;
2957     my $age_protect_only = 0;
2958     for my $copy ( @$copies ) {
2959         @status = verify_copy_for_hold(
2960             $patron, $requestor, $title, $copy, $pickup_lib, $request_lib, $oargs );
2961         $age_protect_only ||= $status[3];
2962         last if $status[0];
2963     }
2964     $status[3] = $age_protect_only;
2965     return @status;
2966 }
2967
2968
2969
2970 sub verify_copy_for_hold {
2971     my( $patron, $requestor, $title, $copy, $pickup_lib, $request_lib, $oargs ) = @_;
2972     # $oargs should be undef unless we're overriding.
2973     $logger->info("checking possibility of copy in hold request for copy ".$copy->id);
2974     my $permitted = OpenILS::Utils::PermitHold::permit_copy_hold(
2975         {
2976             patron           => $patron,
2977             requestor        => $requestor,
2978             copy             => $copy,
2979             title            => $title,
2980             title_descriptor => $title->fixed_fields,
2981             pickup_lib       => $pickup_lib,
2982             request_lib      => $request_lib,
2983             new_hold         => 1,
2984             show_event_list  => 1
2985         }
2986     );
2987
2988     # Check for override permissions on events.
2989     if ($oargs && $permitted && scalar @$permitted) {
2990         # Remove the events from permitted that we can override.
2991         if ($oargs->{events}) {
2992             foreach my $evt (@{$oargs->{events}}) {
2993                 $permitted = [grep {$_->{textcode} ne $evt} @{$permitted}];
2994             }
2995         }
2996         # Now, we handle the override all case by checking remaining
2997         # events against override permissions.
2998         if (scalar @$permitted && $oargs->{all}) {
2999             # Pre-set events and failed members of oargs to empty
3000             # arrays, if they are not set, yet.
3001             $oargs->{events} = [] unless ($oargs->{events});
3002             $oargs->{failed} = [] unless ($oargs->{failed});
3003             # When we're done with these checks, we swap permitted
3004             # with a reference to @disallowed.
3005             my @disallowed = ();
3006             foreach my $evt (@{$permitted}) {
3007                 # Check if we've already seen the event in this
3008                 # session and it failed.
3009                 if (grep {$_ eq $evt->{textcode}} @{$oargs->{failed}}) {
3010                     push(@disallowed, $evt);
3011                 } else {
3012                     # We have to check if the requestor has the
3013                     # override permission.
3014
3015                     # AppUtils::check_user_perms returns the perm if
3016                     # the user doesn't have it, undef if they do.
3017                     if ($apputils->check_user_perms($requestor->id, $requestor->ws_ou, $evt->{textcode} . '.override')) {
3018                         push(@disallowed, $evt);
3019                         push(@{$oargs->{failed}}, $evt->{textcode});
3020                     } else {
3021                         push(@{$oargs->{events}}, $evt->{textcode});
3022                     }
3023                 }
3024             }
3025             $permitted = \@disallowed;
3026         }
3027     }
3028
3029     my $age_protect_only = 0;
3030     if (@$permitted == 1 && @$permitted[0]->{textcode} eq 'ITEM_AGE_PROTECTED') {
3031         $age_protect_only = 1;
3032     }
3033
3034     return (
3035         (not scalar @$permitted), # true if permitted is an empty arrayref
3036         (   # XXX This test is of very dubious value; someone should figure
3037             # out what if anything is checking this value
3038             ($copy->circ_lib == $pickup_lib) and
3039             ($copy->status == OILS_COPY_STATUS_AVAILABLE)
3040         ),
3041         $permitted,
3042         $age_protect_only
3043     );
3044 }
3045
3046
3047
3048 sub find_nearest_permitted_hold {
3049
3050     my $class  = shift;
3051     my $editor = shift;     # CStoreEditor object
3052     my $copy   = shift;     # copy to target
3053     my $user   = shift;     # staff
3054     my $check_only = shift; # do no updates, just see if the copy could fulfill a hold
3055
3056     my $evt = OpenILS::Event->new('ACTION_HOLD_REQUEST_NOT_FOUND');
3057
3058     my $bc = $copy->barcode;
3059
3060     # find any existing holds that already target this copy
3061     my $old_holds = $editor->search_action_hold_request(
3062         {    current_copy => $copy->id,
3063             cancel_time  => undef,
3064             capture_time => undef
3065         }
3066     );
3067
3068     my $hold_stall_interval = $U->ou_ancestor_setting_value($user->ws_ou, OILS_SETTING_HOLD_SOFT_STALL);
3069
3070     $logger->info("circulator: searching for best hold at org ".$user->ws_ou.
3071         " and copy $bc with a hold stalling interval of ". ($hold_stall_interval || "(none)"));
3072
3073     my $fifo = $U->ou_ancestor_setting_value($user->ws_ou, 'circ.holds_fifo');
3074
3075     # the nearest_hold API call now needs this
3076     $copy->call_number($editor->retrieve_asset_call_number($copy->call_number))
3077         unless ref $copy->call_number;
3078
3079     # search for what should be the best holds for this copy to fulfill
3080     my $best_holds = $U->storagereq(
3081         "open-ils.storage.action.hold_request.nearest_hold.atomic", 
3082         $user->ws_ou, $copy, 100, $hold_stall_interval, $fifo );
3083
3084     # Add any pre-targeted holds to the list too? Unless they are already there, anyway.
3085     if ($old_holds) {
3086         for my $holdid (@$old_holds) {
3087             next unless $holdid;
3088             push(@$best_holds, $holdid) unless ( grep { ''.$holdid eq ''.$_ } @$best_holds );
3089         }
3090     }
3091
3092     unless(@$best_holds) {
3093         $logger->info("circulator: no suitable holds found for copy $bc");
3094         return (undef, $evt);
3095     }
3096
3097
3098     my $best_hold;
3099
3100     # for each potential hold, we have to run the permit script
3101     # to make sure the hold is actually permitted.
3102     my %reqr_cache;
3103     my %org_cache;
3104     for my $holdid (@$best_holds) {
3105         next unless $holdid;
3106         $logger->info("circulator: checking if hold $holdid is permitted for copy $bc");
3107
3108         my $hold = $editor->retrieve_action_hold_request($holdid) or next;
3109         # Force and recall holds bypass all rules
3110         if ($hold->hold_type eq 'R' || $hold->hold_type eq 'F') {
3111             $best_hold = $hold;
3112             last;
3113         }
3114         my $reqr = $reqr_cache{$hold->requestor} || $editor->retrieve_actor_user($hold->requestor);
3115         my $rlib = $org_cache{$hold->request_lib} || $editor->retrieve_actor_org_unit($hold->request_lib);
3116
3117         $reqr_cache{$hold->requestor} = $reqr;
3118         $org_cache{$hold->request_lib} = $rlib;
3119
3120         # see if this hold is permitted
3121         my $permitted = OpenILS::Utils::PermitHold::permit_copy_hold(
3122             {
3123                 patron_id   => $hold->usr,
3124                 requestor   => $reqr,
3125                 copy        => $copy,
3126                 pickup_lib  => $hold->pickup_lib,
3127                 request_lib => $rlib,
3128                 retarget    => 1
3129             }
3130         );
3131
3132         if( $permitted ) {
3133             $best_hold = $hold;
3134             last;
3135         }
3136     }
3137
3138
3139     unless( $best_hold ) { # no "good" permitted holds were found
3140         # we got nuthin
3141         $logger->info("circulator: no suitable holds found for copy $bc");
3142         return (undef, $evt);
3143     }
3144
3145     $logger->info("circulator: best hold ".$best_hold->id." found for copy $bc");
3146
3147     # indicate a permitted hold was found
3148     return $best_hold if $check_only;
3149
3150     # we've found a permitted hold.  we need to "grab" the copy
3151     # to prevent re-targeted holds (next part) from re-grabbing the copy
3152     $best_hold->current_copy($copy->id);
3153     $editor->update_action_hold_request($best_hold)
3154         or return (undef, $editor->event);
3155
3156
3157     my @retarget;
3158
3159     # re-target any other holds that already target this copy
3160     for my $old_hold (@$old_holds) {
3161         next if $old_hold->id eq $best_hold->id; # don't re-target the hold we want
3162         $logger->info("circulator: clearing current_copy and prev_check_time on hold ".
3163             $old_hold->id." after a better hold [".$best_hold->id."] was found");
3164         $old_hold->clear_current_copy;
3165         $old_hold->clear_prev_check_time;
3166         $editor->update_action_hold_request($old_hold)
3167             or return (undef, $editor->event);
3168         push(@retarget, $old_hold->id);
3169     }
3170
3171     return ($best_hold, undef, (@retarget) ? \@retarget : undef);
3172 }
3173
3174
3175
3176
3177
3178
3179 __PACKAGE__->register_method(
3180     method   => 'all_rec_holds',
3181     api_name => 'open-ils.circ.holds.retrieve_all_from_title',
3182 );
3183
3184 sub all_rec_holds {
3185     my( $self, $conn, $auth, $title_id, $args ) = @_;
3186
3187     my $e = new_editor(authtoken=>$auth);
3188     $e->checkauth or return $e->event;
3189     $e->allowed('VIEW_HOLD') or return $e->event;
3190
3191     $args ||= {};
3192     $args->{fulfillment_time} = undef; #  we don't want to see old fulfilled holds
3193     $args->{cancel_time} = undef;
3194
3195     my $resp = {
3196           metarecord_holds => []
3197         , title_holds      => []
3198         , volume_holds     => []
3199         , copy_holds       => []
3200         , recall_holds     => []
3201         , force_holds      => []
3202         , part_holds       => []
3203         , issuance_holds   => []
3204     };
3205
3206     my $mr_map = $e->search_metabib_metarecord_source_map({source => $title_id})->[0];
3207     if($mr_map) {
3208         $resp->{metarecord_holds} = $e->search_action_hold_request(
3209             {   hold_type => OILS_HOLD_TYPE_METARECORD,
3210                 target => $mr_map->metarecord,
3211                 %$args
3212             }, {idlist => 1}
3213         );
3214     }
3215
3216     $resp->{title_holds} = $e->search_action_hold_request(
3217         {
3218             hold_type => OILS_HOLD_TYPE_TITLE,
3219             target => $title_id,
3220             %$args
3221         }, {idlist=>1} );
3222
3223     my $parts = $e->search_biblio_monograph_part(
3224         {
3225             record => $title_id
3226         }, {idlist=>1} );
3227
3228     if (@$parts) {
3229         $resp->{part_holds} = $e->search_action_hold_request(
3230             {
3231                 hold_type => OILS_HOLD_TYPE_MONOPART,
3232                 target => $parts,
3233                 %$args
3234             }, {idlist=>1} );
3235     }
3236
3237     my $subs = $e->search_serial_subscription(
3238         { record_entry => $title_id }, {idlist=>1});
3239
3240     if (@$subs) {
3241         my $issuances = $e->search_serial_issuance(
3242             {subscription => $subs}, {idlist=>1}
3243         );
3244
3245         if (@$issuances) {
3246             $resp->{issuance_holds} = $e->search_action_hold_request(
3247                 {
3248                     hold_type => OILS_HOLD_TYPE_ISSUANCE,
3249                     target => $issuances,
3250                     %$args
3251                 }, {idlist=>1}
3252             );
3253         }
3254     }
3255
3256     my $vols = $e->search_asset_call_number(
3257         { record => $title_id, deleted => 'f' }, {idlist=>1});
3258
3259     return $resp unless @$vols;
3260
3261     $resp->{volume_holds} = $e->search_action_hold_request(
3262         {
3263             hold_type => OILS_HOLD_TYPE_VOLUME,
3264             target => $vols,
3265             %$args },
3266         {idlist=>1} );
3267
3268     my $copies = $e->search_asset_copy(
3269         { call_number => $vols, deleted => 'f' }, {idlist=>1});
3270
3271     return $resp unless @$copies;
3272
3273     $resp->{copy_holds} = $e->search_action_hold_request(
3274         {
3275             hold_type => OILS_HOLD_TYPE_COPY,
3276             target => $copies,
3277             %$args },
3278         {idlist=>1} );
3279
3280     $resp->{recall_holds} = $e->search_action_hold_request(
3281         {
3282             hold_type => OILS_HOLD_TYPE_RECALL,
3283             target => $copies,
3284             %$args },
3285         {idlist=>1} );
3286
3287     $resp->{force_holds} = $e->search_action_hold_request(
3288         {
3289             hold_type => OILS_HOLD_TYPE_FORCE,
3290             target => $copies,
3291             %$args },
3292         {idlist=>1} );
3293
3294     return $resp;
3295 }
3296
3297
3298
3299
3300
3301 __PACKAGE__->register_method(
3302     method        => 'uber_hold',
3303     authoritative => 1,
3304     api_name      => 'open-ils.circ.hold.details.retrieve'
3305 );
3306
3307 sub uber_hold {
3308     my($self, $client, $auth, $hold_id, $args) = @_;
3309     my $e = new_editor(authtoken=>$auth);
3310     $e->checkauth or return $e->event;
3311     return uber_hold_impl($e, $hold_id, $args);
3312 }
3313
3314 __PACKAGE__->register_method(
3315     method        => 'batch_uber_hold',
3316     authoritative => 1,
3317     stream        => 1,
3318     api_name      => 'open-ils.circ.hold.details.batch.retrieve'
3319 );
3320
3321 sub batch_uber_hold {
3322     my($self, $client, $auth, $hold_ids, $args) = @_;
3323     my $e = new_editor(authtoken=>$auth);
3324     $e->checkauth or return $e->event;
3325     $client->respond(uber_hold_impl($e, $_, $args)) for @$hold_ids;
3326     return undef;
3327 }
3328
3329 sub uber_hold_impl {
3330     my($e, $hold_id, $args) = @_;
3331     $args ||= {};
3332
3333     my $hold = $e->retrieve_action_hold_request(
3334         [
3335             $hold_id,
3336             {
3337                 flesh => 1,
3338                 flesh_fields => { ahr => [ 'current_copy', 'usr', 'notes' ] }
3339             }
3340         ]
3341     ) or return $e->event;
3342
3343     if($hold->usr->id ne $e->requestor->id) {
3344         # caller is asking for someone else's hold
3345         $e->allowed('VIEW_HOLD') or return $e->event;
3346         $hold->notes( # filter out any non-staff ("private") notes (unless marked as public)
3347             [ grep { $U->is_true($_->staff) or $U->is_true($_->pub) } @{$hold->notes} ] );
3348
3349     } else {
3350         # caller is asking for own hold, but may not have permission to view staff notes
3351         unless($e->allowed('VIEW_HOLD')) {
3352             $hold->notes( # filter out any staff notes (unless marked as public)
3353                 [ grep { !$U->is_true($_->staff) or $U->is_true($_->pub) } @{$hold->notes} ] );
3354         }
3355     }
3356
3357     my $user = $hold->usr;
3358     $hold->usr($user->id);
3359
3360
3361     my( $mvr, $volume, $copy, $issuance, $part, $bre ) = find_hold_mvr($e, $hold, $args->{suppress_mvr});
3362
3363     flesh_hold_notices([$hold], $e) unless $args->{suppress_notices};
3364     flesh_hold_transits([$hold]) unless $args->{suppress_transits};
3365
3366     my $details = retrieve_hold_queue_status_impl($e, $hold);
3367
3368     my $resp = {
3369         hold    => $hold,
3370         bre_id  => $bre->id,
3371         ($copy     ? (copy           => $copy)     : ()),
3372         ($volume   ? (volume         => $volume)   : ()),
3373         ($issuance ? (issuance       => $issuance) : ()),
3374         ($part     ? (part           => $part)     : ()),
3375         ($args->{include_bre}  ?  (bre => $bre)    : ()),
3376         ($args->{suppress_mvr} ?  () : (mvr => $mvr)),
3377         %$details
3378     };
3379
3380     unless($args->{suppress_patron_details}) {
3381         my $card = $e->retrieve_actor_card($user->card) or return $e->event;
3382         $resp->{patron_first}   = $user->first_given_name,
3383         $resp->{patron_last}    = $user->family_name,
3384         $resp->{patron_barcode} = $card->barcode,
3385         $resp->{patron_alias}   = $user->alias,
3386     };
3387
3388     return $resp;
3389 }
3390
3391
3392
3393 # -----------------------------------------------------
3394 # Returns the MVR object that represents what the
3395 # hold is all about
3396 # -----------------------------------------------------
3397 sub find_hold_mvr {
3398     my( $e, $hold, $no_mvr ) = @_;
3399
3400     my $tid;
3401     my $copy;
3402     my $volume;
3403     my $issuance;
3404     my $part;
3405
3406     if( $hold->hold_type eq OILS_HOLD_TYPE_METARECORD ) {
3407         my $mr = $e->retrieve_metabib_metarecord($hold->target)
3408             or return $e->event;
3409         $tid = $mr->master_record;
3410
3411     } elsif( $hold->hold_type eq OILS_HOLD_TYPE_TITLE ) {
3412         $tid = $hold->target;
3413
3414     } elsif( $hold->hold_type eq OILS_HOLD_TYPE_VOLUME ) {
3415         $volume = $e->retrieve_asset_call_number($hold->target)
3416             or return $e->event;
3417         $tid = $volume->record;
3418
3419     } elsif( $hold->hold_type eq OILS_HOLD_TYPE_ISSUANCE ) {
3420         $issuance = $e->retrieve_serial_issuance([
3421             $hold->target,
3422             {flesh => 1, flesh_fields => {siss => [ qw/subscription/ ]}}
3423         ]) or return $e->event;
3424
3425         $tid = $issuance->subscription->record_entry;
3426
3427     } elsif( $hold->hold_type eq OILS_HOLD_TYPE_MONOPART ) {
3428         $part = $e->retrieve_biblio_monograph_part([
3429             $hold->target
3430         ]) or return $e->event;
3431
3432         $tid = $part->record;
3433
3434     } 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 ) {
3435         $copy = $e->retrieve_asset_copy([
3436             $hold->target,
3437             {flesh => 1, flesh_fields => {acp => ['call_number']}}
3438         ]) or return $e->event;
3439
3440         $volume = $copy->call_number;
3441         $tid = $volume->record;
3442     }
3443
3444     if(!$copy and ref $hold->current_copy ) {
3445         $copy = $hold->current_copy;
3446         $hold->current_copy($copy->id);
3447     }
3448
3449     if(!$volume and $copy) {
3450         $volume = $e->retrieve_asset_call_number($copy->call_number);
3451     }
3452
3453     # TODO return metarcord mvr for M holds
3454     my $title = $e->retrieve_biblio_record_entry($tid);
3455     return ( ($no_mvr) ? undef : $U->record_to_mvr($title), $volume, $copy, $issuance, $part, $title );
3456 }
3457
3458 __PACKAGE__->register_method(
3459     method    => 'clear_shelf_cache',
3460     api_name  => 'open-ils.circ.hold.clear_shelf.get_cache',
3461     stream    => 1,
3462     signature => {
3463         desc => q/
3464             Returns the holds processed with the given cache key
3465         /
3466     }
3467 );
3468
3469 sub clear_shelf_cache {
3470     my($self, $client, $auth, $cache_key, $chunk_size) = @_;
3471     my $e = new_editor(authtoken => $auth, xact => 1);
3472     return $e->die_event unless $e->checkauth and $e->allowed('VIEW_HOLD');
3473
3474     $chunk_size ||= 25;
3475     my $hold_data = OpenSRF::Utils::Cache->new('global')->get_cache($cache_key);
3476
3477     if (!$hold_data) {
3478         $logger->info("no hold data found in cache"); # XXX TODO return event
3479         $e->rollback;
3480         return undef;
3481     }
3482
3483     my $maximum = 0;
3484     foreach (keys %$hold_data) {
3485         $maximum += scalar(@{ $hold_data->{$_} });
3486     }
3487     $client->respond({"maximum" => $maximum, "progress" => 0});
3488
3489     for my $action (sort keys %$hold_data) {
3490         while (@{$hold_data->{$action}}) {
3491             my @hid_chunk = splice @{$hold_data->{$action}}, 0, $chunk_size;
3492
3493             my $result_chunk = $e->json_query({
3494                 "select" => {
3495                     "acp" => ["barcode"],
3496                     "au" => [qw/
3497                         first_given_name second_given_name family_name alias
3498                     /],
3499                     "acn" => ["label"],
3500                     "acnp" => [{column => "label", alias => "prefix"}],
3501                     "acns" => [{column => "label", alias => "suffix"}],
3502                     "bre" => ["marc"],
3503                     "acpl" => ["name"],
3504                     "ahr" => ["id"]
3505                 },
3506                 "from" => {
3507                     "ahr" => {
3508                         "acp" => {
3509                             "field" => "id", "fkey" => "current_copy",
3510                             "join" => {
3511                                 "acn" => {
3512                                     "field" => "id", "fkey" => "call_number",
3513                                     "join" => {
3514                                         "bre" => {
3515                                             "field" => "id", "fkey" => "record"
3516                                         },
3517                                         "acnp" => {
3518                                             "field" => "id", "fkey" => "prefix"
3519                                         },
3520                                         "acns" => {
3521                                             "field" => "id", "fkey" => "suffix"
3522                                         }
3523                                     }
3524                                 },
3525                                 "acpl" => {"field" => "id", "fkey" => "location"}
3526                             }
3527                         },
3528                         "au" => {"field" => "id", "fkey" => "usr"}
3529                     }
3530                 },
3531                 "where" => {"+ahr" => {"id" => \@hid_chunk}}
3532             }, {"substream" => 1}) or return $e->die_event;
3533
3534             $client->respond([
3535                 map {
3536                     +{"action" => $action, "hold_details" => $_}
3537                 } @$result_chunk
3538             ]);
3539         }
3540     }
3541
3542     $e->rollback;
3543     return undef;
3544 }
3545
3546
3547 __PACKAGE__->register_method(
3548     method    => 'clear_shelf_process',
3549     stream    => 1,
3550     api_name  => 'open-ils.circ.hold.clear_shelf.process',
3551     signature => {
3552         desc => q/
3553             1. Find all holds that have expired on the holds shelf
3554             2. Cancel the holds
3555             3. If a clear-shelf status is configured, put targeted copies into this status
3556             4. Divide copies into 3 groups: items to transit, items to reshelve, and items
3557                 that are needed for holds.  No subsequent action is taken on the holds
3558                 or items after grouping.
3559         /
3560     }
3561 );
3562
3563 sub clear_shelf_process {
3564     my($self, $client, $auth, $org_id, $match_copy) = @_;
3565
3566     my $e = new_editor(authtoken=>$auth);
3567     $e->checkauth or return $e->die_event;
3568     my $cache = OpenSRF::Utils::Cache->new('global');
3569
3570     $org_id ||= $e->requestor->ws_ou;
3571     $e->allowed('UPDATE_HOLD', $org_id) or return $e->die_event;
3572
3573     my $copy_status = $U->ou_ancestor_setting_value($org_id, 'circ.holds.clear_shelf.copy_status');
3574
3575     my @hold_ids = $self->method_lookup(
3576         "open-ils.circ.captured_holds.id_list.expired_on_shelf.retrieve"
3577     )->run($auth, $org_id, $match_copy);
3578
3579     $e->xact_begin;
3580
3581     my @holds;
3582     my @canceled_holds; # newly canceled holds
3583     my $chunk_size = 25; # chunked status updates
3584     my $counter = 0;
3585     for my $hold_id (@hold_ids) {
3586
3587         $logger->info("Clear shelf processing hold $hold_id");
3588
3589         my $hold = $e->retrieve_action_hold_request([
3590             $hold_id, {
3591                 flesh => 1,
3592                 flesh_fields => {ahr => ['current_copy']}
3593             }
3594         ]);
3595
3596         if (!$hold->cancel_time) { # may be canceled but still on the holds shelf
3597             $hold->cancel_time('now');
3598             $hold->cancel_cause(2); # Hold Shelf expiration
3599             $e->update_action_hold_request($hold) or return $e->die_event;
3600             delete_hold_copy_maps($self, $e, $hold->id) and return $e->die_event;
3601             push(@canceled_holds, $hold_id);
3602         }
3603
3604         my $copy = $hold->current_copy;
3605
3606         if($copy_status or $copy_status == 0) {
3607             # if a clear-shelf copy status is defined, update the copy
3608             $copy->status($copy_status);
3609             $copy->edit_date('now');
3610             $copy->editor($e->requestor->id);
3611             $e->update_asset_copy($copy) or return $e->die_event;
3612         }
3613
3614         push(@holds, $hold);
3615         $client->respond({maximum => scalar(@holds), progress => $counter}) if ( (++$counter % $chunk_size) == 0);
3616     }
3617
3618     if ($e->commit) {
3619
3620         my %cache_data = (
3621             hold => [],
3622             transit => [],
3623             shelf => [],
3624             pl_changed => pickup_lib_changed_on_shelf_holds($e, $org_id, \@hold_ids)
3625         );
3626
3627         for my $hold (@holds) {
3628
3629             my $copy = $hold->current_copy;
3630             my ($alt_hold) = __PACKAGE__->find_nearest_permitted_hold($e, $copy, $e->requestor, 1);
3631
3632             if($alt_hold and !$match_copy) {
3633
3634                 push(@{$cache_data{hold}}, $hold->id); # copy is needed for a hold
3635
3636             } elsif($copy->circ_lib != $e->requestor->ws_ou) {
3637
3638                 push(@{$cache_data{transit}}, $hold->id); # copy needs to transit
3639
3640             } else {
3641
3642                 push(@{$cache_data{shelf}}, $hold->id); # copy needs to go back to the shelf
3643             }
3644         }
3645
3646         my $cache_key = md5_hex(time . $$ . rand());
3647         $logger->info("clear_shelf_cache: storing under $cache_key");
3648         $cache->put_cache($cache_key, \%cache_data, 7200); # TODO: 2 hours.  configurable?
3649
3650         # tell the client we're done
3651         $client->respond_complete({cache_key => $cache_key});
3652
3653         # ------------
3654         # fire off the hold cancelation trigger and wait for response so don't flood the service
3655
3656         # refetch the holds to pick up the caclulated cancel_time,
3657         # which may be needed by Action/Trigger
3658         $e->xact_begin;
3659         my $updated_holds = [];
3660         $updated_holds = $e->search_action_hold_request({id => \@canceled_holds}, {substream => 1}) if (@canceled_holds > 0);
3661         $e->rollback;
3662
3663         $U->create_events_for_hook(
3664             'hold_request.cancel.expire_holds_shelf',
3665             $_, $org_id, undef, undef, 1) for @$updated_holds;
3666
3667     } else {
3668         # tell the client we're done
3669         $client->respond_complete;
3670     }
3671 }
3672
3673 # returns IDs for holds that are on the holds shelf but 
3674 # have had their pickup_libs change while on the shelf.
3675 sub pickup_lib_changed_on_shelf_holds {
3676     my $e = shift;
3677     my $org_id = shift;
3678     my $ignore_holds = shift;
3679     $ignore_holds = [$ignore_holds] if !ref($ignore_holds);
3680
3681     my $query = {
3682         select => { alhr => ['id'] },
3683         from   => {
3684             alhr => {
3685                 acp => {
3686                     field => 'id',
3687                     fkey  => 'current_copy'
3688                 },
3689             }
3690         },
3691         where => {
3692             '+acp' => { status => OILS_COPY_STATUS_ON_HOLDS_SHELF },
3693             '+alhr' => {
3694                 capture_time     => { "!=" => undef },
3695                 fulfillment_time => undef,
3696                 current_shelf_lib => $org_id,
3697                 pickup_lib => {'!='  => {'+alhr' => 'current_shelf_lib'}}
3698             }
3699         }
3700     };
3701
3702     $query->{where}->{'+alhr'}->{id} =
3703         {'not in' => $ignore_holds} if @$ignore_holds;
3704
3705     my $hold_ids = $e->json_query($query);
3706     return [ map { $_->{id} } @$hold_ids ];
3707 }
3708
3709 __PACKAGE__->register_method(
3710     method    => 'usr_hold_summary',
3711     api_name  => 'open-ils.circ.holds.user_summary',
3712     signature => q/
3713         Returns a summary of holds statuses for a given user
3714     /
3715 );
3716
3717 sub usr_hold_summary {
3718     my($self, $conn, $auth, $user_id) = @_;
3719
3720     my $e = new_editor(authtoken=>$auth);
3721     $e->checkauth or return $e->event;
3722     $e->allowed('VIEW_HOLD') or return $e->event;
3723
3724     my $holds = $e->search_action_hold_request(
3725         {
3726             usr =>  $user_id ,
3727             fulfillment_time => undef,
3728             cancel_time      => undef,
3729         }
3730     );
3731
3732     my %summary = (1 => 0, 2 => 0, 3 => 0, 4 => 0);
3733     $summary{_hold_status($e, $_)} += 1 for @$holds;
3734     return \%summary;
3735 }
3736
3737
3738
3739 __PACKAGE__->register_method(
3740     method    => 'hold_has_copy_at',
3741     api_name  => 'open-ils.circ.hold.has_copy_at',
3742     signature => {
3743         desc   =>
3744                 'Returns the ID of the found copy and name of the shelving location if there is ' .
3745                 'an available copy at the specified org unit.  Returns empty hash otherwise.  '   .
3746                 'The anticipated use for this method is to determine whether an item is '         .
3747                 'available at the library where the user is placing the hold (or, alternatively, '.
3748                 'at the pickup library) to encourage bypassing the hold placement and just '      .
3749                 'checking out the item.' ,
3750         params => [
3751             { desc => 'Authentication Token', type => 'string' },
3752             { desc => 'Method Arguments.  Options include: hold_type, hold_target, org_unit.  '
3753                     . 'hold_type is the hold type code (T, V, C, M, ...).  '
3754                     . 'hold_target is the identifier of the hold target object.  '
3755                     . 'org_unit is org unit ID.',
3756               type => 'object'
3757             }
3758         ],
3759         return => {
3760             desc => q/Result hash like { "copy" : copy_id, "location" : location_name }, empty hash on misses, event on error./,
3761             type => 'object'
3762         }
3763     }
3764 );
3765
3766 sub hold_has_copy_at {
3767     my($self, $conn, $auth, $args) = @_;
3768
3769     my $e = new_editor(authtoken=>$auth);
3770     $e->checkauth or return $e->event;
3771
3772     my $hold_type   = $$args{hold_type};
3773     my $hold_target = $$args{hold_target};
3774     my $org_unit    = $$args{org_unit};
3775
3776     my $query = {
3777         select => {acp => ['id'], acpl => ['name']},
3778         from   => {
3779             acp => {
3780                 acpl => {field => 'id', filter => { holdable => 't'}, fkey => 'location'},
3781                 ccs  => {field => 'id', filter => { holdable => 't'}, fkey => 'status'  }
3782             }
3783         },
3784         where => {'+acp' => { circulate => 't', deleted => 'f', holdable => 't', circ_lib => $org_unit, status => [0,7]}},
3785         limit => 1
3786     };
3787
3788     if($hold_type eq 'C' or $hold_type eq 'F' or $hold_type eq 'R') {
3789
3790         $query->{where}->{'+acp'}->{id} = $hold_target;
3791
3792     } elsif($hold_type eq 'V') {
3793
3794         $query->{where}->{'+acp'}->{call_number} = $hold_target;
3795
3796     } elsif($hold_type eq 'P') {
3797
3798         $query->{from}->{acp}->{acpm} = {
3799             field  => 'target_copy',
3800             fkey   => 'id',
3801             filter => {part => $hold_target},
3802         };
3803
3804     } elsif($hold_type eq 'I') {
3805
3806         $query->{from}->{acp}->{sitem} = {
3807             field  => 'unit',
3808             fkey   => 'id',
3809             filter => {issuance => $hold_target},
3810         };
3811
3812     } elsif($hold_type eq 'T') {
3813
3814         $query->{from}->{acp}->{acn} = {
3815             field  => 'id',
3816             fkey   => 'call_number',
3817             'join' => {
3818                 bre => {
3819                     field  => 'id',
3820                     filter => {id => $hold_target},
3821                     fkey   => 'record'
3822                 }
3823             }
3824         };
3825
3826     } else {
3827
3828         $query->{from}->{acp}->{acn} = {
3829             field => 'id',
3830             fkey  => 'call_number',
3831             join  => {
3832                 bre => {
3833                     field => 'id',
3834                     fkey  => 'record',
3835                     join  => {
3836                         mmrsm => {
3837                             field  => 'source',
3838                             fkey   => 'id',
3839                             filter => {metarecord => $hold_target},
3840                         }
3841                     }
3842                 }
3843             }
3844         };
3845     }
3846
3847     my $res = $e->json_query($query)->[0] or return {};
3848     return {copy => $res->{id}, location => $res->{name}} if $res;
3849 }
3850
3851
3852 # returns true if the user already has an item checked out
3853 # that could be used to fulfill the requested hold.
3854 sub hold_item_is_checked_out {
3855     my($e, $user_id, $hold_type, $hold_target) = @_;
3856
3857     my $query = {
3858         select => {acp => ['id']},
3859         from   => {acp => {}},
3860         where  => {
3861             '+acp' => {
3862                 id => {
3863                     in => { # copies for circs the user has checked out
3864                         select => {circ => ['target_copy']},
3865                         from   => 'circ',
3866                         where  => {
3867                             usr => $user_id,
3868                             checkin_time => undef,
3869                             '-or' => [
3870                                 {stop_fines => ["MAXFINES","LONGOVERDUE"]},
3871                                 {stop_fines => undef}
3872                             ],
3873                         }
3874                     }
3875                 }
3876             }
3877         },
3878         limit => 1
3879     };
3880
3881     if($hold_type eq 'C' || $hold_type eq 'R' || $hold_type eq 'F') {
3882
3883         $query->{where}->{'+acp'}->{id}->{in}->{where}->{'target_copy'} = $hold_target;
3884
3885     } elsif($hold_type eq 'V') {
3886
3887         $query->{where}->{'+acp'}->{call_number} = $hold_target;
3888
3889      } elsif($hold_type eq 'P') {
3890
3891         $query->{from}->{acp}->{acpm} = {
3892             field  => 'target_copy',
3893             fkey   => 'id',
3894             filter => {part => $hold_target},
3895         };
3896
3897      } elsif($hold_type eq 'I') {
3898
3899         $query->{from}->{acp}->{sitem} = {
3900             field  => 'unit',
3901             fkey   => 'id',
3902             filter => {issuance => $hold_target},
3903         };
3904
3905     } elsif($hold_type eq 'T') {
3906
3907         $query->{from}->{acp}->{acn} = {
3908             field  => 'id',
3909             fkey   => 'call_number',
3910             'join' => {
3911                 bre => {
3912                     field  => 'id',
3913                     filter => {id => $hold_target},
3914                     fkey   => 'record'
3915                 }
3916             }
3917         };
3918
3919     } else {
3920
3921         $query->{from}->{acp}->{acn} = {
3922             field => 'id',
3923             fkey => 'call_number',
3924             join => {
3925                 bre => {
3926                     field => 'id',
3927                     fkey => 'record',
3928                     join => {
3929                         mmrsm => {
3930                             field => 'source',
3931                             fkey => 'id',
3932                             filter => {metarecord => $hold_target},
3933                         }
3934                     }
3935                 }
3936             }
3937         };
3938     }
3939
3940     return $e->json_query($query)->[0];
3941 }
3942
3943 __PACKAGE__->register_method(
3944     method    => 'change_hold_title',
3945     api_name  => 'open-ils.circ.hold.change_title',
3946     signature => {
3947         desc => q/
3948             Updates all title level holds targeting the specified bibs to point a new bib./,
3949         params => [
3950             { desc => 'Authentication Token', type => 'string' },
3951             { desc => 'New Target Bib Id',    type => 'number' },
3952             { desc => 'Old Target Bib Ids',   type => 'array'  },
3953         ],
3954         return => { desc => '1 on success' }
3955     }
3956 );
3957
3958 __PACKAGE__->register_method(
3959     method    => 'change_hold_title_for_specific_holds',
3960     api_name  => 'open-ils.circ.hold.change_title.specific_holds',
3961     signature => {
3962         desc => q/
3963             Updates specified holds to target new bib./,
3964         params => [
3965             { desc => 'Authentication Token', type => 'string' },
3966             { desc => 'New Target Bib Id',    type => 'number' },
3967             { desc => 'Holds Ids for holds to update',   type => 'array'  },
3968         ],
3969         return => { desc => '1 on success' }
3970     }
3971 );
3972
3973
3974 sub change_hold_title {
3975     my( $self, $client, $auth, $new_bib_id, $bib_ids ) = @_;
3976
3977     my $e = new_editor(authtoken=>$auth, xact=>1);
3978     return $e->die_event unless $e->checkauth;
3979
3980     my $holds = $e->search_action_hold_request(
3981         [
3982             {
3983                 cancel_time      => undef,
3984                 fulfillment_time => undef,
3985                 hold_type        => 'T',
3986                 target           => $bib_ids
3987             },
3988             {
3989                 flesh        => 1,
3990                 flesh_fields => { ahr => ['usr'] }
3991             }
3992         ],
3993         { substream => 1 }
3994     );
3995
3996     for my $hold (@$holds) {
3997         $e->allowed('UPDATE_HOLD', $hold->usr->home_ou) or return $e->die_event;
3998         $logger->info("Changing hold " . $hold->id . " target from " . $hold->target . " to $new_bib_id in title hold target change");
3999         $hold->target( $new_bib_id );
4000         $e->update_action_hold_request($hold) or return $e->die_event;
4001     }
4002
4003     $e->commit;
4004
4005     _reset_hold($self, $e->requestor, $_) for @$holds;
4006
4007     return 1;
4008 }
4009
4010 sub change_hold_title_for_specific_holds {
4011     my( $self, $client, $auth, $new_bib_id, $hold_ids ) = @_;
4012
4013     my $e = new_editor(authtoken=>$auth, xact=>1);
4014     return $e->die_event unless $e->checkauth;
4015
4016     my $holds = $e->search_action_hold_request(
4017         [
4018             {
4019                 cancel_time      => undef,
4020                 fulfillment_time => undef,
4021                 hold_type        => 'T',
4022                 id               => $hold_ids
4023             },
4024             {
4025                 flesh        => 1,
4026                 flesh_fields => { ahr => ['usr'] }
4027             }
4028         ],
4029         { substream => 1 }
4030     );
4031
4032     for my $hold (@$holds) {
4033         $e->allowed('UPDATE_HOLD', $hold->usr->home_ou) or return $e->die_event;
4034         $logger->info("Changing hold " . $hold->id . " target from " . $hold->target . " to $new_bib_id in title hold target change");
4035         $hold->target( $new_bib_id );
4036         $e->update_action_hold_request($hold) or return $e->die_event;
4037     }
4038
4039     $e->commit;
4040
4041     _reset_hold($self, $e->requestor, $_) for @$holds;
4042
4043     return 1;
4044 }
4045
4046 __PACKAGE__->register_method(
4047     method    => 'rec_hold_count',
4048     api_name  => 'open-ils.circ.bre.holds.count',
4049     signature => {
4050         desc => q/Returns the total number of holds that target the
4051             selected bib record or its associated copies and call_numbers/,
4052         params => [
4053             { desc => 'Bib ID', type => 'number' },
4054             { desc => q/Optional arguments.  Supported arguments include:
4055                 "pickup_lib_descendant" -> limit holds to those whose pickup
4056                 library is equal to or is a child of the provided org unit/,
4057                 type => 'object'
4058             }
4059         ],
4060         return => {desc => 'Hold count', type => 'number'}
4061     }
4062 );
4063
4064 __PACKAGE__->register_method(
4065     method    => 'rec_hold_count',
4066     api_name  => 'open-ils.circ.mmr.holds.count',
4067     signature => {
4068         desc => q/Returns the total number of holds that target the
4069             selected metarecord or its associated copies, call_numbers, and bib records/,
4070         params => [
4071             { desc => 'Metarecord ID', type => 'number' },
4072         ],
4073         return => {desc => 'Hold count', type => 'number'}
4074     }
4075 );
4076
4077 # XXX Need to add type I (and, soon, type P) holds to these counts
4078 sub rec_hold_count {
4079     my($self, $conn, $target_id, $args) = @_;
4080     $args ||= {};
4081
4082     my $mmr_join = {
4083         mmrsm => {
4084             field => 'id',
4085             fkey => 'source',
4086             filter => {metarecord => $target_id}
4087         }
4088     };
4089
4090     my $bre_join = {
4091         bre => {
4092             field => 'id',
4093             filter => { id => $target_id },
4094             fkey => 'record'
4095         }
4096     };
4097
4098     if($self->api_name =~ /mmr/) {
4099         delete $bre_join->{bre}->{filter};
4100         $bre_join->{bre}->{join} = $mmr_join;
4101     }
4102
4103     my $cn_join = {
4104         acn => {
4105             field => 'id',
4106             fkey => 'call_number',
4107             join => $bre_join
4108         }
4109     };
4110
4111     my $query = {
4112         select => {ahr => [{column => 'id', transform => 'count', alias => 'count'}]},
4113         from => 'ahr',
4114         where => {
4115             '+ahr' => {
4116                 cancel_time => undef,
4117                 fulfillment_time => undef,
4118                 '-or' => [
4119                     {
4120                         '-and' => {
4121                             hold_type => [qw/C F R/],
4122                             target => {
4123                                 in => {
4124                                     select => {acp => ['id']},
4125                                     from => { acp => $cn_join }
4126                                 }
4127                             }
4128                         }
4129                     },
4130                     {
4131                         '-and' => {
4132                             hold_type => 'V',
4133                             target => {
4134                                 in => {
4135                                     select => {acn => ['id']},
4136                                     from => {acn => $bre_join}
4137                                 }
4138                             }
4139                         }
4140                     },
4141                     {
4142                         '-and' => {
4143                             hold_type => 'T',
4144                             target => $target_id
4145                         }
4146                     }
4147                 ]
4148             }
4149         }
4150     };
4151
4152     if($self->api_name =~ /mmr/) {
4153         $query->{where}->{'+ahr'}->{'-or'}->[2] = {
4154             '-and' => {
4155                 hold_type => 'T',
4156                 target => {
4157                     in => {
4158                         select => {bre => ['id']},
4159                         from => {bre => $mmr_join}
4160                     }
4161                 }
4162             }
4163         };
4164
4165         $query->{where}->{'+ahr'}->{'-or'}->[3] = {
4166             '-and' => {
4167                 hold_type => 'M',
4168                 target => $target_id
4169             }
4170         };
4171     }
4172
4173
4174     if (my $pld = $args->{pickup_lib_descendant}) {
4175
4176         my $top_ou = new_editor()->search_actor_org_unit(
4177             {parent_ou => undef}
4178         )->[0]; # XXX Assumes single root node. Not alone in this...
4179
4180         $query->{where}->{'+ahr'}->{pickup_lib} = {
4181             in => {
4182                 select  => {aou => [{ 
4183                     column => 'id', 
4184                     transform => 'actor.org_unit_descendants', 
4185                     result_field => 'id' 
4186                 }]},
4187                 from    => 'aou',
4188                 where   => {id => $pld}
4189             }
4190         } if ($pld != $top_ou->id);
4191     }
4192
4193
4194     return new_editor()->json_query($query)->[0]->{count};
4195 }
4196
4197 # A helper function to calculate a hold's expiration time at a given
4198 # org_unit. Takes the org_unit as an argument and returns either the
4199 # hold expire time as an ISO8601 string or undef if there is no hold
4200 # expiration interval set for the subject ou.
4201 sub calculate_expire_time
4202 {
4203     my $ou = shift;
4204     my $interval = $U->ou_ancestor_setting_value($ou, OILS_SETTING_HOLD_EXPIRE);
4205     if($interval) {
4206         my $date = DateTime->now->add(seconds => OpenSRF::Utils::interval_to_seconds($interval));
4207         return $U->epoch2ISO8601($date->epoch);
4208     }
4209     return undef;
4210 }
4211
4212
4213 __PACKAGE__->register_method(
4214     method    => 'mr_hold_filter_attrs',
4215     api_name  => 'open-ils.circ.mmr.holds.filters',
4216     authoritative => 1,
4217     stream => 1,
4218     signature => {
4219         desc => q/
4220             Returns the set of available formats and languages for the
4221             constituent records of the provided metarcord.
4222             If an array of hold IDs is also provided, information about
4223             each is returned as well.  This information includes:
4224             1. a slightly easier to read version of holdable_formats
4225             2. attributes describing the set of format icons included
4226                in the set of desired, constituent records.
4227         /,
4228         params => [
4229             {desc => 'Metarecord ID', type => 'number'},
4230             {desc => 'Hold ID List', type => 'array'},
4231         ],
4232         return => {
4233             desc => q/
4234                 Stream of objects.  The first will have a 'metarecord' key
4235                 containing non-hold-specific metarecord information, subsequent
4236                 responses will contain a 'hold' key containing hold-specific
4237                 information
4238             /, 
4239             type => 'object'
4240         }
4241     }
4242 );
4243
4244 sub mr_hold_filter_attrs {
4245     my ($self, $client, $mr_id, $hold_ids) = @_;
4246     my $e = new_editor();
4247
4248
4249     my $mr = $e->retrieve_metabib_metarecord($mr_id) or return $e->event;
4250     my $bre_ids = $e->json_query({
4251         select => {mmrsm => ['source']},
4252         from => 'mmrsm',
4253         where => {'+mmrsm' => {metarecord => $mr_id}}
4254     });
4255     $bre_ids = [map {$_->{source}} @$bre_ids];
4256
4257     my $item_lang_attr = 'item_lang'; # configurable?
4258     my $format_attr = $e->retrieve_config_global_flag(
4259         'opac.metarecord.holds.format_attr')->value;
4260
4261     # helper sub for fetching ccvms for a batch of record IDs
4262     sub get_batch_ccvms {
4263         my ($e, $attr, $bre_ids) = @_;
4264         return [] unless $bre_ids and @$bre_ids;
4265         my $vals = $e->search_metabib_record_attr_flat({
4266             attr => $attr,
4267             id => $bre_ids
4268         });
4269         return [] unless @$vals;
4270         return $e->search_config_coded_value_map({
4271             ctype => $attr,
4272             code => [map {$_->value} @$vals]
4273         });
4274     }
4275
4276     my $langs = get_batch_ccvms($e, $item_lang_attr, $bre_ids);
4277     my $formats = get_batch_ccvms($e, $format_attr, $bre_ids);
4278
4279     $client->respond({
4280         metarecord => {
4281             id => $mr_id,
4282             formats => $formats,
4283             langs => $langs
4284         }
4285     });
4286
4287     return unless $hold_ids;
4288     my $icon_attr = $e->retrieve_config_global_flag('opac.icon_attr');
4289     $icon_attr = $icon_attr ? $icon_attr->value : '';
4290
4291     for my $hold_id (@$hold_ids) {
4292         my $hold = $e->retrieve_action_hold_request($hold_id) 
4293             or return $e->event;
4294
4295         next unless $hold->hold_type eq 'M';
4296
4297         my $resp = {
4298             hold => {
4299                 id => $hold_id,
4300                 formats => [],
4301                 langs => []
4302             }
4303         };
4304
4305         # collect the ccvm's for the selected formats / language (
4306         # (i.e. the holdable formats) on the MR.
4307         # this assumes a two-key structure for format / language,
4308         # though assumption is made about the keys themselves.
4309         my $hformats = OpenSRF::Utils::JSON->JSON2perl($hold->holdable_formats);
4310         my $lang_vals = [];
4311         my $format_vals = [];
4312         for my $val (values %$hformats) {
4313             # val is either a single ccvm or an array of them
4314             $val = [$val] unless ref $val eq 'ARRAY';
4315             for my $node (@$val) {
4316                 push (@$lang_vals, $node->{_val})   
4317                     if $node->{_attr} eq $item_lang_attr; 
4318                 push (@$format_vals, $node->{_val})   
4319                     if $node->{_attr} eq $format_attr;
4320             }
4321         }
4322
4323         # fetch the ccvm's for consistency with the {metarecord} blob
4324         $resp->{hold}{formats} = $e->search_config_coded_value_map({
4325             ctype => $format_attr, code => $format_vals});
4326         $resp->{hold}{langs} = $e->search_config_coded_value_map({
4327             ctype => $item_lang_attr, code => $lang_vals});
4328
4329         # find all of the bib records within this metarcord whose 
4330         # format / language match the holdable formats on the hold
4331         my ($bre_ids) = $self->method_lookup(
4332             'open-ils.circ.holds.metarecord.filterd_records')->run(
4333                 $hold->target, $hold->holdable_formats);
4334
4335         # now find all of the 'icon' attributes for the records
4336         $resp->{hold}{icons} = get_batch_ccvms($e, $icon_attr, $bre_ids);
4337         $client->respond($resp);
4338     }
4339
4340     return;
4341 }
4342
4343 1;