]> git.evergreen-ils.org Git - working/Evergreen.git/blob - Open-ILS/src/perlmods/lib/OpenILS/Application/Circ/Holds.pm
LP#1321429: Use server-local time as best-guess
[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(time_zone => 'local'); # without time_zone we get UTC ... yuck!
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         # pasing undef as the depth to filtered_records causes the depth
2481         # of the selection_ou to be used, which is not what we want here.
2482         $depth ||= 0;
2483
2484         my ($recs) = __PACKAGE__->method_lookup('open-ils.circ.holds.metarecord.filtered_records')->run($mrid, $holdable_formats, $selection_ou, $depth);
2485         my @status = ();
2486         for my $rec (@$recs) {
2487             @status = _check_title_hold_is_possible(
2488                 $rec, $depth, $request_lib, $patron, $e->requestor, $pickup_lib, $selection_ou, $holdable_formats, $oargs
2489             );
2490             last if $status[0];
2491         }
2492         return @status;
2493     }
2494 #   else { Unrecognized hold_type ! }   # FIXME: return error? or 0?
2495 }
2496
2497 sub MR_filter_records {
2498     my $self = shift;
2499     my $client = shift;
2500     my $m = shift;
2501     my $f = shift;
2502     my $o = shift;
2503     my $d = shift;
2504     my $opac_visible = shift;
2505     
2506     my $org_at_depth = defined($d) ? $U->org_unit_ancestor_at_depth($o, $d) : $o;
2507     return $U->storagereq(
2508         'open-ils.storage.metarecord.filtered_records.atomic', 
2509         $m, $f, $org_at_depth, $opac_visible
2510     );
2511 }
2512 __PACKAGE__->register_method(
2513     method   => 'MR_filter_records',
2514     api_name => 'open-ils.circ.holds.metarecord.filtered_records',
2515 );
2516
2517
2518 my %prox_cache;
2519 sub create_ranged_org_filter {
2520     my($e, $selection_ou, $depth) = @_;
2521
2522     # find the orgs from which this hold may be fulfilled,
2523     # based on the selection_ou and depth
2524
2525     my $top_org = $e->search_actor_org_unit([
2526         {parent_ou => undef},
2527         {flesh=>1, flesh_fields=>{aou=>['ou_type']}}])->[0];
2528     my %org_filter;
2529
2530     return () if $depth == $top_org->ou_type->depth;
2531
2532     my $org_list = $U->storagereq('open-ils.storage.actor.org_unit.descendants.atomic', $selection_ou, $depth);
2533     %org_filter = (circ_lib => []);
2534     push(@{$org_filter{circ_lib}}, $_->id) for @$org_list;
2535
2536     $logger->info("hold org filter at depth $depth and selection_ou ".
2537         "$selection_ou created list of @{$org_filter{circ_lib}}");
2538
2539     return %org_filter;
2540 }
2541
2542
2543 sub _check_title_hold_is_possible {
2544     my( $titleid, $depth, $request_lib, $patron, $requestor, $pickup_lib, $selection_ou, $holdable_formats, $oargs ) = @_;
2545     # $holdable_formats is now unused. We pre-filter the MR's records.
2546
2547     my $e = new_editor();
2548     my %org_filter = create_ranged_org_filter($e, $selection_ou, $depth);
2549
2550     # this monster will grab the id and circ_lib of all of the "holdable" copies for the given record
2551     my $copies = $e->json_query(
2552         {
2553             select => { acp => ['id', 'circ_lib'] },
2554               from => {
2555                 acp => {
2556                     acn => {
2557                         field  => 'id',
2558                         fkey   => 'call_number',
2559                         filter => { record => $titleid }
2560                     },
2561                     acpl => { field => 'id', filter => { holdable => 't'}, fkey => 'location' },
2562                     ccs  => { field => 'id', filter => { holdable => 't'}, fkey => 'status'   },
2563                     acpm => { field => 'target_copy', type => 'left' } # ignore part-linked copies
2564                 }
2565             },
2566             where => {
2567                 '+acp' => { circulate => 't', deleted => 'f', holdable => 't', %org_filter },
2568                 '+acpm' => { target_copy => undef } # ignore part-linked copies
2569             }
2570         }
2571     );
2572
2573     $logger->info("title possible found ".scalar(@$copies)." potential copies");
2574     return (
2575         0, 0, [
2576             new OpenILS::Event(
2577                 "HIGH_LEVEL_HOLD_HAS_NO_COPIES",
2578                 "payload" => {"fail_part" => "no_ultimate_items"}
2579             )
2580         ]
2581     ) unless @$copies;
2582
2583     # -----------------------------------------------------------------------
2584     # sort the copies into buckets based on their circ_lib proximity to
2585     # the patron's home_ou.
2586     # -----------------------------------------------------------------------
2587
2588     my $home_org = $patron->home_ou;
2589     my $req_org = $request_lib->id;
2590
2591     $logger->info("prox cache $home_org " . $prox_cache{$home_org});
2592
2593     $prox_cache{$home_org} =
2594         $e->search_actor_org_unit_proximity({from_org => $home_org})
2595         unless $prox_cache{$home_org};
2596     my $home_prox = $prox_cache{$home_org};
2597
2598     my %buckets;
2599     my %hash = map { ($_->to_org => $_->prox) } @$home_prox;
2600     push( @{$buckets{ $hash{$_->{circ_lib}} } }, $_->{id} ) for @$copies;
2601
2602     my @keys = sort { $a <=> $b } keys %buckets;
2603
2604
2605     if( $home_org ne $req_org ) {
2606       # -----------------------------------------------------------------------
2607       # shove the copies close to the request_lib into the primary buckets
2608       # directly before the farthest away copies.  That way, they are not
2609       # given priority, but they are checked before the farthest copies.
2610       # -----------------------------------------------------------------------
2611         $prox_cache{$req_org} =
2612             $e->search_actor_org_unit_proximity({from_org => $req_org})
2613             unless $prox_cache{$req_org};
2614         my $req_prox = $prox_cache{$req_org};
2615
2616         my %buckets2;
2617         my %hash2 = map { ($_->to_org => $_->prox) } @$req_prox;
2618         push( @{$buckets2{ $hash2{$_->{circ_lib}} } }, $_->{id} ) for @$copies;
2619
2620         my $highest_key = $keys[@keys - 1];  # the farthest prox in the exising buckets
2621         my $new_key = $highest_key - 0.5; # right before the farthest prox
2622         my @keys2   = sort { $a <=> $b } keys %buckets2;
2623         for my $key (@keys2) {
2624             last if $key >= $highest_key;
2625             push( @{$buckets{$new_key}}, $_ ) for @{$buckets2{$key}};
2626         }
2627     }
2628
2629     @keys = sort { $a <=> $b } keys %buckets;
2630
2631     my $title;
2632     my %seen;
2633     my @status;
2634     my $age_protect_only = 0;
2635     OUTER: for my $key (@keys) {
2636       my @cps = @{$buckets{$key}};
2637
2638       $logger->info("looking at " . scalar(@{$buckets{$key}}). " copies in proximity bucket $key");
2639
2640       for my $copyid (@cps) {
2641
2642          next if $seen{$copyid};
2643          $seen{$copyid} = 1; # there could be dupes given the merged buckets
2644          my $copy = $e->retrieve_asset_copy($copyid);
2645          $logger->debug("looking at bucket_key=$key, copy $copyid : circ_lib = " . $copy->circ_lib);
2646
2647          unless($title) { # grab the title if we don't already have it
2648             my $vol = $e->retrieve_asset_call_number(
2649                [ $copy->call_number, { flesh => 1, flesh_fields => { bre => ['fixed_fields'], acn => ['record'] } } ] );
2650             $title = $vol->record;
2651          }
2652
2653          @status = verify_copy_for_hold(
2654             $patron, $requestor, $title, $copy, $pickup_lib, $request_lib, $oargs);
2655
2656          $age_protect_only ||= $status[3];
2657          last OUTER if $status[0];
2658       }
2659     }
2660
2661     $status[3] = $age_protect_only;
2662     return @status;
2663 }
2664
2665 sub _check_issuance_hold_is_possible {
2666     my( $issuanceid, $depth, $request_lib, $patron, $requestor, $pickup_lib, $selection_ou, $oargs ) = @_;
2667
2668     my $e = new_editor();
2669     my %org_filter = create_ranged_org_filter($e, $selection_ou, $depth);
2670
2671     # this monster will grab the id and circ_lib of all of the "holdable" copies for the given record
2672     my $copies = $e->json_query(
2673         {
2674             select => { acp => ['id', 'circ_lib'] },
2675               from => {
2676                 acp => {
2677                     sitem => {
2678                         field  => 'unit',
2679                         fkey   => 'id',
2680                         filter => { issuance => $issuanceid }
2681                     },
2682                     acpl => { field => 'id', filter => { holdable => 't'}, fkey => 'location' },
2683                     ccs  => { field => 'id', filter => { holdable => 't'}, fkey => 'status'   }
2684                 }
2685             },
2686             where => {
2687                 '+acp' => { circulate => 't', deleted => 'f', holdable => 't', %org_filter }
2688             },
2689             distinct => 1
2690         }
2691     );
2692
2693     $logger->info("issuance possible found ".scalar(@$copies)." potential copies");
2694
2695     my $empty_ok;
2696     if (!@$copies) {
2697         $empty_ok = $e->retrieve_config_global_flag('circ.holds.empty_issuance_ok');
2698         $empty_ok = ($empty_ok and $U->is_true($empty_ok->enabled));
2699
2700         return (
2701             0, 0, [
2702                 new OpenILS::Event(
2703                     "HIGH_LEVEL_HOLD_HAS_NO_COPIES",
2704                     "payload" => {"fail_part" => "no_ultimate_items"}
2705                 )
2706             ]
2707         ) unless $empty_ok;
2708
2709         return (1, 0);
2710     }
2711
2712     # -----------------------------------------------------------------------
2713     # sort the copies into buckets based on their circ_lib proximity to
2714     # the patron's home_ou.
2715     # -----------------------------------------------------------------------
2716
2717     my $home_org = $patron->home_ou;
2718     my $req_org = $request_lib->id;
2719
2720     $logger->info("prox cache $home_org " . $prox_cache{$home_org});
2721
2722     $prox_cache{$home_org} =
2723         $e->search_actor_org_unit_proximity({from_org => $home_org})
2724         unless $prox_cache{$home_org};
2725     my $home_prox = $prox_cache{$home_org};
2726
2727     my %buckets;
2728     my %hash = map { ($_->to_org => $_->prox) } @$home_prox;
2729     push( @{$buckets{ $hash{$_->{circ_lib}} } }, $_->{id} ) for @$copies;
2730
2731     my @keys = sort { $a <=> $b } keys %buckets;
2732
2733
2734     if( $home_org ne $req_org ) {
2735       # -----------------------------------------------------------------------
2736       # shove the copies close to the request_lib into the primary buckets
2737       # directly before the farthest away copies.  That way, they are not
2738       # given priority, but they are checked before the farthest copies.
2739       # -----------------------------------------------------------------------
2740         $prox_cache{$req_org} =
2741             $e->search_actor_org_unit_proximity({from_org => $req_org})
2742             unless $prox_cache{$req_org};
2743         my $req_prox = $prox_cache{$req_org};
2744
2745         my %buckets2;
2746         my %hash2 = map { ($_->to_org => $_->prox) } @$req_prox;
2747         push( @{$buckets2{ $hash2{$_->{circ_lib}} } }, $_->{id} ) for @$copies;
2748
2749         my $highest_key = $keys[@keys - 1];  # the farthest prox in the exising buckets
2750         my $new_key = $highest_key - 0.5; # right before the farthest prox
2751         my @keys2   = sort { $a <=> $b } keys %buckets2;
2752         for my $key (@keys2) {
2753             last if $key >= $highest_key;
2754             push( @{$buckets{$new_key}}, $_ ) for @{$buckets2{$key}};
2755         }
2756     }
2757
2758     @keys = sort { $a <=> $b } keys %buckets;
2759
2760     my $title;
2761     my %seen;
2762     my @status;
2763     my $age_protect_only = 0;
2764     OUTER: for my $key (@keys) {
2765       my @cps = @{$buckets{$key}};
2766
2767       $logger->info("looking at " . scalar(@{$buckets{$key}}). " copies in proximity bucket $key");
2768
2769       for my $copyid (@cps) {
2770
2771          next if $seen{$copyid};
2772          $seen{$copyid} = 1; # there could be dupes given the merged buckets
2773          my $copy = $e->retrieve_asset_copy($copyid);
2774          $logger->debug("looking at bucket_key=$key, copy $copyid : circ_lib = " . $copy->circ_lib);
2775
2776          unless($title) { # grab the title if we don't already have it
2777             my $vol = $e->retrieve_asset_call_number(
2778                [ $copy->call_number, { flesh => 1, flesh_fields => { bre => ['fixed_fields'], acn => ['record'] } } ] );
2779             $title = $vol->record;
2780          }
2781
2782          @status = verify_copy_for_hold(
2783             $patron, $requestor, $title, $copy, $pickup_lib, $request_lib, $oargs);
2784
2785          $age_protect_only ||= $status[3];
2786          last OUTER if $status[0];
2787       }
2788     }
2789
2790     if (!$status[0]) {
2791         if (!defined($empty_ok)) {
2792             $empty_ok = $e->retrieve_config_global_flag('circ.holds.empty_issuance_ok');
2793             $empty_ok = ($empty_ok and $U->is_true($empty_ok->enabled));
2794         }
2795
2796         return (1,0) if ($empty_ok);
2797     }
2798     $status[3] = $age_protect_only;
2799     return @status;
2800 }
2801
2802 sub _check_monopart_hold_is_possible {
2803     my( $partid, $depth, $request_lib, $patron, $requestor, $pickup_lib, $selection_ou, $oargs ) = @_;
2804
2805     my $e = new_editor();
2806     my %org_filter = create_ranged_org_filter($e, $selection_ou, $depth);
2807
2808     # this monster will grab the id and circ_lib of all of the "holdable" copies for the given record
2809     my $copies = $e->json_query(
2810         {
2811             select => { acp => ['id', 'circ_lib'] },
2812               from => {
2813                 acp => {
2814                     acpm => {
2815                         field  => 'target_copy',
2816                         fkey   => 'id',
2817                         filter => { part => $partid }
2818                     },
2819                     acpl => { field => 'id', filter => { holdable => 't'}, fkey => 'location' },
2820                     ccs  => { field => 'id', filter => { holdable => 't'}, fkey => 'status'   }
2821                 }
2822             },
2823             where => {
2824                 '+acp' => { circulate => 't', deleted => 'f', holdable => 't', %org_filter }
2825             },
2826             distinct => 1
2827         }
2828     );
2829
2830     $logger->info("monopart possible found ".scalar(@$copies)." potential copies");
2831
2832     my $empty_ok;
2833     if (!@$copies) {
2834         $empty_ok = $e->retrieve_config_global_flag('circ.holds.empty_part_ok');
2835         $empty_ok = ($empty_ok and $U->is_true($empty_ok->enabled));
2836
2837         return (
2838             0, 0, [
2839                 new OpenILS::Event(
2840                     "HIGH_LEVEL_HOLD_HAS_NO_COPIES",
2841                     "payload" => {"fail_part" => "no_ultimate_items"}
2842                 )
2843             ]
2844         ) unless $empty_ok;
2845
2846         return (1, 0);
2847     }
2848
2849     # -----------------------------------------------------------------------
2850     # sort the copies into buckets based on their circ_lib proximity to
2851     # the patron's home_ou.
2852     # -----------------------------------------------------------------------
2853
2854     my $home_org = $patron->home_ou;
2855     my $req_org = $request_lib->id;
2856
2857     $logger->info("prox cache $home_org " . $prox_cache{$home_org});
2858
2859     $prox_cache{$home_org} =
2860         $e->search_actor_org_unit_proximity({from_org => $home_org})
2861         unless $prox_cache{$home_org};
2862     my $home_prox = $prox_cache{$home_org};
2863
2864     my %buckets;
2865     my %hash = map { ($_->to_org => $_->prox) } @$home_prox;
2866     push( @{$buckets{ $hash{$_->{circ_lib}} } }, $_->{id} ) for @$copies;
2867
2868     my @keys = sort { $a <=> $b } keys %buckets;
2869
2870
2871     if( $home_org ne $req_org ) {
2872       # -----------------------------------------------------------------------
2873       # shove the copies close to the request_lib into the primary buckets
2874       # directly before the farthest away copies.  That way, they are not
2875       # given priority, but they are checked before the farthest copies.
2876       # -----------------------------------------------------------------------
2877         $prox_cache{$req_org} =
2878             $e->search_actor_org_unit_proximity({from_org => $req_org})
2879             unless $prox_cache{$req_org};
2880         my $req_prox = $prox_cache{$req_org};
2881
2882         my %buckets2;
2883         my %hash2 = map { ($_->to_org => $_->prox) } @$req_prox;
2884         push( @{$buckets2{ $hash2{$_->{circ_lib}} } }, $_->{id} ) for @$copies;
2885
2886         my $highest_key = $keys[@keys - 1];  # the farthest prox in the exising buckets
2887         my $new_key = $highest_key - 0.5; # right before the farthest prox
2888         my @keys2   = sort { $a <=> $b } keys %buckets2;
2889         for my $key (@keys2) {
2890             last if $key >= $highest_key;
2891             push( @{$buckets{$new_key}}, $_ ) for @{$buckets2{$key}};
2892         }
2893     }
2894
2895     @keys = sort { $a <=> $b } keys %buckets;
2896
2897     my $title;
2898     my %seen;
2899     my @status;
2900     my $age_protect_only = 0;
2901     OUTER: for my $key (@keys) {
2902       my @cps = @{$buckets{$key}};
2903
2904       $logger->info("looking at " . scalar(@{$buckets{$key}}). " copies in proximity bucket $key");
2905
2906       for my $copyid (@cps) {
2907
2908          next if $seen{$copyid};
2909          $seen{$copyid} = 1; # there could be dupes given the merged buckets
2910          my $copy = $e->retrieve_asset_copy($copyid);
2911          $logger->debug("looking at bucket_key=$key, copy $copyid : circ_lib = " . $copy->circ_lib);
2912
2913          unless($title) { # grab the title if we don't already have it
2914             my $vol = $e->retrieve_asset_call_number(
2915                [ $copy->call_number, { flesh => 1, flesh_fields => { bre => ['fixed_fields'], acn => ['record'] } } ] );
2916             $title = $vol->record;
2917          }
2918
2919          @status = verify_copy_for_hold(
2920             $patron, $requestor, $title, $copy, $pickup_lib, $request_lib, $oargs);
2921
2922          $age_protect_only ||= $status[3];
2923          last OUTER if $status[0];
2924       }
2925     }
2926
2927     if (!$status[0]) {
2928         if (!defined($empty_ok)) {
2929             $empty_ok = $e->retrieve_config_global_flag('circ.holds.empty_part_ok');
2930             $empty_ok = ($empty_ok and $U->is_true($empty_ok->enabled));
2931         }
2932
2933         return (1,0) if ($empty_ok);
2934     }
2935     $status[3] = $age_protect_only;
2936     return @status;
2937 }
2938
2939
2940 sub _check_volume_hold_is_possible {
2941     my( $vol, $title, $depth, $request_lib, $patron, $requestor, $pickup_lib, $selection_ou, $oargs ) = @_;
2942     my %org_filter = create_ranged_org_filter(new_editor(), $selection_ou, $depth);
2943     my $copies = new_editor->search_asset_copy({call_number => $vol->id, %org_filter});
2944     $logger->info("checking possibility of volume hold for volume ".$vol->id);
2945
2946     my $filter_copies = [];
2947     for my $copy (@$copies) {
2948         # ignore part-mapped copies for regular volume level holds
2949         push(@$filter_copies, $copy) unless
2950             new_editor->search_asset_copy_part_map({target_copy => $copy->id})->[0];
2951     }
2952     $copies = $filter_copies;
2953
2954     return (
2955         0, 0, [
2956             new OpenILS::Event(
2957                 "HIGH_LEVEL_HOLD_HAS_NO_COPIES",
2958                 "payload" => {"fail_part" => "no_ultimate_items"}
2959             )
2960         ]
2961     ) unless @$copies;
2962
2963     my @status;
2964     my $age_protect_only = 0;
2965     for my $copy ( @$copies ) {
2966         @status = verify_copy_for_hold(
2967             $patron, $requestor, $title, $copy, $pickup_lib, $request_lib, $oargs );
2968         $age_protect_only ||= $status[3];
2969         last if $status[0];
2970     }
2971     $status[3] = $age_protect_only;
2972     return @status;
2973 }
2974
2975
2976
2977 sub verify_copy_for_hold {
2978     my( $patron, $requestor, $title, $copy, $pickup_lib, $request_lib, $oargs ) = @_;
2979     # $oargs should be undef unless we're overriding.
2980     $logger->info("checking possibility of copy in hold request for copy ".$copy->id);
2981     my $permitted = OpenILS::Utils::PermitHold::permit_copy_hold(
2982         {
2983             patron           => $patron,
2984             requestor        => $requestor,
2985             copy             => $copy,
2986             title            => $title,
2987             title_descriptor => $title->fixed_fields,
2988             pickup_lib       => $pickup_lib,
2989             request_lib      => $request_lib,
2990             new_hold         => 1,
2991             show_event_list  => 1
2992         }
2993     );
2994
2995     # Check for override permissions on events.
2996     if ($oargs && $permitted && scalar @$permitted) {
2997         # Remove the events from permitted that we can override.
2998         if ($oargs->{events}) {
2999             foreach my $evt (@{$oargs->{events}}) {
3000                 $permitted = [grep {$_->{textcode} ne $evt} @{$permitted}];
3001             }
3002         }
3003         # Now, we handle the override all case by checking remaining
3004         # events against override permissions.
3005         if (scalar @$permitted && $oargs->{all}) {
3006             # Pre-set events and failed members of oargs to empty
3007             # arrays, if they are not set, yet.
3008             $oargs->{events} = [] unless ($oargs->{events});
3009             $oargs->{failed} = [] unless ($oargs->{failed});
3010             # When we're done with these checks, we swap permitted
3011             # with a reference to @disallowed.
3012             my @disallowed = ();
3013             foreach my $evt (@{$permitted}) {
3014                 # Check if we've already seen the event in this
3015                 # session and it failed.
3016                 if (grep {$_ eq $evt->{textcode}} @{$oargs->{failed}}) {
3017                     push(@disallowed, $evt);
3018                 } else {
3019                     # We have to check if the requestor has the
3020                     # override permission.
3021
3022                     # AppUtils::check_user_perms returns the perm if
3023                     # the user doesn't have it, undef if they do.
3024                     if ($apputils->check_user_perms($requestor->id, $requestor->ws_ou, $evt->{textcode} . '.override')) {
3025                         push(@disallowed, $evt);
3026                         push(@{$oargs->{failed}}, $evt->{textcode});
3027                     } else {
3028                         push(@{$oargs->{events}}, $evt->{textcode});
3029                     }
3030                 }
3031             }
3032             $permitted = \@disallowed;
3033         }
3034     }
3035
3036     my $age_protect_only = 0;
3037     if (@$permitted == 1 && @$permitted[0]->{textcode} eq 'ITEM_AGE_PROTECTED') {
3038         $age_protect_only = 1;
3039     }
3040
3041     return (
3042         (not scalar @$permitted), # true if permitted is an empty arrayref
3043         (   # XXX This test is of very dubious value; someone should figure
3044             # out what if anything is checking this value
3045             ($copy->circ_lib == $pickup_lib) and
3046             ($copy->status == OILS_COPY_STATUS_AVAILABLE)
3047         ),
3048         $permitted,
3049         $age_protect_only
3050     );
3051 }
3052
3053
3054
3055 sub find_nearest_permitted_hold {
3056
3057     my $class  = shift;
3058     my $editor = shift;     # CStoreEditor object
3059     my $copy   = shift;     # copy to target
3060     my $user   = shift;     # staff
3061     my $check_only = shift; # do no updates, just see if the copy could fulfill a hold
3062
3063     my $evt = OpenILS::Event->new('ACTION_HOLD_REQUEST_NOT_FOUND');
3064
3065     my $bc = $copy->barcode;
3066
3067     # find any existing holds that already target this copy
3068     my $old_holds = $editor->search_action_hold_request(
3069         {    current_copy => $copy->id,
3070             cancel_time  => undef,
3071             capture_time => undef
3072         }
3073     );
3074
3075     my $hold_stall_interval = $U->ou_ancestor_setting_value($user->ws_ou, OILS_SETTING_HOLD_SOFT_STALL);
3076
3077     $logger->info("circulator: searching for best hold at org ".$user->ws_ou.
3078         " and copy $bc with a hold stalling interval of ". ($hold_stall_interval || "(none)"));
3079
3080     my $fifo = $U->ou_ancestor_setting_value($user->ws_ou, 'circ.holds_fifo');
3081
3082     # the nearest_hold API call now needs this
3083     $copy->call_number($editor->retrieve_asset_call_number($copy->call_number))
3084         unless ref $copy->call_number;
3085
3086     # search for what should be the best holds for this copy to fulfill
3087     my $best_holds = $U->storagereq(
3088         "open-ils.storage.action.hold_request.nearest_hold.atomic", 
3089         $user->ws_ou, $copy, 100, $hold_stall_interval, $fifo );
3090
3091     # Add any pre-targeted holds to the list too? Unless they are already there, anyway.
3092     if ($old_holds) {
3093         for my $holdid (@$old_holds) {
3094             next unless $holdid;
3095             push(@$best_holds, $holdid) unless ( grep { ''.$holdid eq ''.$_ } @$best_holds );
3096         }
3097     }
3098
3099     unless(@$best_holds) {
3100         $logger->info("circulator: no suitable holds found for copy $bc");
3101         return (undef, $evt);
3102     }
3103
3104
3105     my $best_hold;
3106
3107     # for each potential hold, we have to run the permit script
3108     # to make sure the hold is actually permitted.
3109     my %reqr_cache;
3110     my %org_cache;
3111     for my $holdid (@$best_holds) {
3112         next unless $holdid;
3113         $logger->info("circulator: checking if hold $holdid is permitted for copy $bc");
3114
3115         my $hold = $editor->retrieve_action_hold_request($holdid) or next;
3116         # Force and recall holds bypass all rules
3117         if ($hold->hold_type eq 'R' || $hold->hold_type eq 'F') {
3118             $best_hold = $hold;
3119             last;
3120         }
3121         my $reqr = $reqr_cache{$hold->requestor} || $editor->retrieve_actor_user($hold->requestor);
3122         my $rlib = $org_cache{$hold->request_lib} || $editor->retrieve_actor_org_unit($hold->request_lib);
3123
3124         $reqr_cache{$hold->requestor} = $reqr;
3125         $org_cache{$hold->request_lib} = $rlib;
3126
3127         # see if this hold is permitted
3128         my $permitted = OpenILS::Utils::PermitHold::permit_copy_hold(
3129             {
3130                 patron_id   => $hold->usr,
3131                 requestor   => $reqr,
3132                 copy        => $copy,
3133                 pickup_lib  => $hold->pickup_lib,
3134                 request_lib => $rlib,
3135                 retarget    => 1
3136             }
3137         );
3138
3139         if( $permitted ) {
3140             $best_hold = $hold;
3141             last;
3142         }
3143     }
3144
3145
3146     unless( $best_hold ) { # no "good" permitted holds were found
3147         # we got nuthin
3148         $logger->info("circulator: no suitable holds found for copy $bc");
3149         return (undef, $evt);
3150     }
3151
3152     $logger->info("circulator: best hold ".$best_hold->id." found for copy $bc");
3153
3154     # indicate a permitted hold was found
3155     return $best_hold if $check_only;
3156
3157     # we've found a permitted hold.  we need to "grab" the copy
3158     # to prevent re-targeted holds (next part) from re-grabbing the copy
3159     $best_hold->current_copy($copy->id);
3160     $editor->update_action_hold_request($best_hold)
3161         or return (undef, $editor->event);
3162
3163
3164     my @retarget;
3165
3166     # re-target any other holds that already target this copy
3167     for my $old_hold (@$old_holds) {
3168         next if $old_hold->id eq $best_hold->id; # don't re-target the hold we want
3169         $logger->info("circulator: clearing current_copy and prev_check_time on hold ".
3170             $old_hold->id." after a better hold [".$best_hold->id."] was found");
3171         $old_hold->clear_current_copy;
3172         $old_hold->clear_prev_check_time;
3173         $editor->update_action_hold_request($old_hold)
3174             or return (undef, $editor->event);
3175         push(@retarget, $old_hold->id);
3176     }
3177
3178     return ($best_hold, undef, (@retarget) ? \@retarget : undef);
3179 }
3180
3181
3182
3183
3184
3185
3186 __PACKAGE__->register_method(
3187     method   => 'all_rec_holds',
3188     api_name => 'open-ils.circ.holds.retrieve_all_from_title',
3189 );
3190
3191 sub all_rec_holds {
3192     my( $self, $conn, $auth, $title_id, $args ) = @_;
3193
3194     my $e = new_editor(authtoken=>$auth);
3195     $e->checkauth or return $e->event;
3196     $e->allowed('VIEW_HOLD') or return $e->event;
3197
3198     $args ||= {};
3199     $args->{fulfillment_time} = undef; #  we don't want to see old fulfilled holds
3200     $args->{cancel_time} = undef;
3201
3202     my $resp = {
3203           metarecord_holds => []
3204         , title_holds      => []
3205         , volume_holds     => []
3206         , copy_holds       => []
3207         , recall_holds     => []
3208         , force_holds      => []
3209         , part_holds       => []
3210         , issuance_holds   => []
3211     };
3212
3213     my $mr_map = $e->search_metabib_metarecord_source_map({source => $title_id})->[0];
3214     if($mr_map) {
3215         $resp->{metarecord_holds} = $e->search_action_hold_request(
3216             {   hold_type => OILS_HOLD_TYPE_METARECORD,
3217                 target => $mr_map->metarecord,
3218                 %$args
3219             }, {idlist => 1}
3220         );
3221     }
3222
3223     $resp->{title_holds} = $e->search_action_hold_request(
3224         {
3225             hold_type => OILS_HOLD_TYPE_TITLE,
3226             target => $title_id,
3227             %$args
3228         }, {idlist=>1} );
3229
3230     my $parts = $e->search_biblio_monograph_part(
3231         {
3232             record => $title_id
3233         }, {idlist=>1} );
3234
3235     if (@$parts) {
3236         $resp->{part_holds} = $e->search_action_hold_request(
3237             {
3238                 hold_type => OILS_HOLD_TYPE_MONOPART,
3239                 target => $parts,
3240                 %$args
3241             }, {idlist=>1} );
3242     }
3243
3244     my $subs = $e->search_serial_subscription(
3245         { record_entry => $title_id }, {idlist=>1});
3246
3247     if (@$subs) {
3248         my $issuances = $e->search_serial_issuance(
3249             {subscription => $subs}, {idlist=>1}
3250         );
3251
3252         if (@$issuances) {
3253             $resp->{issuance_holds} = $e->search_action_hold_request(
3254                 {
3255                     hold_type => OILS_HOLD_TYPE_ISSUANCE,
3256                     target => $issuances,
3257                     %$args
3258                 }, {idlist=>1}
3259             );
3260         }
3261     }
3262
3263     my $vols = $e->search_asset_call_number(
3264         { record => $title_id, deleted => 'f' }, {idlist=>1});
3265
3266     return $resp unless @$vols;
3267
3268     $resp->{volume_holds} = $e->search_action_hold_request(
3269         {
3270             hold_type => OILS_HOLD_TYPE_VOLUME,
3271             target => $vols,
3272             %$args },
3273         {idlist=>1} );
3274
3275     my $copies = $e->search_asset_copy(
3276         { call_number => $vols, deleted => 'f' }, {idlist=>1});
3277
3278     return $resp unless @$copies;
3279
3280     $resp->{copy_holds} = $e->search_action_hold_request(
3281         {
3282             hold_type => OILS_HOLD_TYPE_COPY,
3283             target => $copies,
3284             %$args },
3285         {idlist=>1} );
3286
3287     $resp->{recall_holds} = $e->search_action_hold_request(
3288         {
3289             hold_type => OILS_HOLD_TYPE_RECALL,
3290             target => $copies,
3291             %$args },
3292         {idlist=>1} );
3293
3294     $resp->{force_holds} = $e->search_action_hold_request(
3295         {
3296             hold_type => OILS_HOLD_TYPE_FORCE,
3297             target => $copies,
3298             %$args },
3299         {idlist=>1} );
3300
3301     return $resp;
3302 }
3303
3304
3305
3306
3307
3308 __PACKAGE__->register_method(
3309     method        => 'uber_hold',
3310     authoritative => 1,
3311     api_name      => 'open-ils.circ.hold.details.retrieve'
3312 );
3313
3314 sub uber_hold {
3315     my($self, $client, $auth, $hold_id, $args) = @_;
3316     my $e = new_editor(authtoken=>$auth);
3317     $e->checkauth or return $e->event;
3318     return uber_hold_impl($e, $hold_id, $args);
3319 }
3320
3321 __PACKAGE__->register_method(
3322     method        => 'batch_uber_hold',
3323     authoritative => 1,
3324     stream        => 1,
3325     api_name      => 'open-ils.circ.hold.details.batch.retrieve'
3326 );
3327
3328 sub batch_uber_hold {
3329     my($self, $client, $auth, $hold_ids, $args) = @_;
3330     my $e = new_editor(authtoken=>$auth);
3331     $e->checkauth or return $e->event;
3332     $client->respond(uber_hold_impl($e, $_, $args)) for @$hold_ids;
3333     return undef;
3334 }
3335
3336 sub uber_hold_impl {
3337     my($e, $hold_id, $args) = @_;
3338     $args ||= {};
3339
3340     my $hold = $e->retrieve_action_hold_request(
3341         [
3342             $hold_id,
3343             {
3344                 flesh => 1,
3345                 flesh_fields => { ahr => [ 'current_copy', 'usr', 'notes' ] }
3346             }
3347         ]
3348     ) or return $e->event;
3349
3350     if($hold->usr->id ne $e->requestor->id) {
3351         # caller is asking for someone else's hold
3352         $e->allowed('VIEW_HOLD') or return $e->event;
3353         $hold->notes( # filter out any non-staff ("private") notes (unless marked as public)
3354             [ grep { $U->is_true($_->staff) or $U->is_true($_->pub) } @{$hold->notes} ] );
3355
3356     } else {
3357         # caller is asking for own hold, but may not have permission to view staff notes
3358         unless($e->allowed('VIEW_HOLD')) {
3359             $hold->notes( # filter out any staff notes (unless marked as public)
3360                 [ grep { !$U->is_true($_->staff) or $U->is_true($_->pub) } @{$hold->notes} ] );
3361         }
3362     }
3363
3364     my $user = $hold->usr;
3365     $hold->usr($user->id);
3366
3367
3368     my( $mvr, $volume, $copy, $issuance, $part, $bre ) = find_hold_mvr($e, $hold, $args->{suppress_mvr});
3369
3370     flesh_hold_notices([$hold], $e) unless $args->{suppress_notices};
3371     flesh_hold_transits([$hold]) unless $args->{suppress_transits};
3372
3373     my $details = retrieve_hold_queue_status_impl($e, $hold);
3374
3375     my $resp = {
3376         hold    => $hold,
3377         bre_id  => $bre->id,
3378         ($copy     ? (copy           => $copy)     : ()),
3379         ($volume   ? (volume         => $volume)   : ()),
3380         ($issuance ? (issuance       => $issuance) : ()),
3381         ($part     ? (part           => $part)     : ()),
3382         ($args->{include_bre}  ?  (bre => $bre)    : ()),
3383         ($args->{suppress_mvr} ?  () : (mvr => $mvr)),
3384         %$details
3385     };
3386
3387     unless($args->{suppress_patron_details}) {
3388         my $card = $e->retrieve_actor_card($user->card) or return $e->event;
3389         $resp->{patron_first}   = $user->first_given_name,
3390         $resp->{patron_last}    = $user->family_name,
3391         $resp->{patron_barcode} = $card->barcode,
3392         $resp->{patron_alias}   = $user->alias,
3393     };
3394
3395     return $resp;
3396 }
3397
3398
3399
3400 # -----------------------------------------------------
3401 # Returns the MVR object that represents what the
3402 # hold is all about
3403 # -----------------------------------------------------
3404 sub find_hold_mvr {
3405     my( $e, $hold, $no_mvr ) = @_;
3406
3407     my $tid;
3408     my $copy;
3409     my $volume;
3410     my $issuance;
3411     my $part;
3412
3413     if( $hold->hold_type eq OILS_HOLD_TYPE_METARECORD ) {
3414         my $mr = $e->retrieve_metabib_metarecord($hold->target)
3415             or return $e->event;
3416         $tid = $mr->master_record;
3417
3418     } elsif( $hold->hold_type eq OILS_HOLD_TYPE_TITLE ) {
3419         $tid = $hold->target;
3420
3421     } elsif( $hold->hold_type eq OILS_HOLD_TYPE_VOLUME ) {
3422         $volume = $e->retrieve_asset_call_number($hold->target)
3423             or return $e->event;
3424         $tid = $volume->record;
3425
3426     } elsif( $hold->hold_type eq OILS_HOLD_TYPE_ISSUANCE ) {
3427         $issuance = $e->retrieve_serial_issuance([
3428             $hold->target,
3429             {flesh => 1, flesh_fields => {siss => [ qw/subscription/ ]}}
3430         ]) or return $e->event;
3431
3432         $tid = $issuance->subscription->record_entry;
3433
3434     } elsif( $hold->hold_type eq OILS_HOLD_TYPE_MONOPART ) {
3435         $part = $e->retrieve_biblio_monograph_part([
3436             $hold->target
3437         ]) or return $e->event;
3438
3439         $tid = $part->record;
3440
3441     } 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 ) {
3442         $copy = $e->retrieve_asset_copy([
3443             $hold->target,
3444             {flesh => 1, flesh_fields => {acp => ['call_number']}}
3445         ]) or return $e->event;
3446
3447         $volume = $copy->call_number;
3448         $tid = $volume->record;
3449     }
3450
3451     if(!$copy and ref $hold->current_copy ) {
3452         $copy = $hold->current_copy;
3453         $hold->current_copy($copy->id);
3454     }
3455
3456     if(!$volume and $copy) {
3457         $volume = $e->retrieve_asset_call_number($copy->call_number);
3458     }
3459
3460     # TODO return metarcord mvr for M holds
3461     my $title = $e->retrieve_biblio_record_entry($tid);
3462     return ( ($no_mvr) ? undef : $U->record_to_mvr($title), $volume, $copy, $issuance, $part, $title );
3463 }
3464
3465 __PACKAGE__->register_method(
3466     method    => 'clear_shelf_cache',
3467     api_name  => 'open-ils.circ.hold.clear_shelf.get_cache',
3468     stream    => 1,
3469     signature => {
3470         desc => q/
3471             Returns the holds processed with the given cache key
3472         /
3473     }
3474 );
3475
3476 sub clear_shelf_cache {
3477     my($self, $client, $auth, $cache_key, $chunk_size) = @_;
3478     my $e = new_editor(authtoken => $auth, xact => 1);
3479     return $e->die_event unless $e->checkauth and $e->allowed('VIEW_HOLD');
3480
3481     $chunk_size ||= 25;
3482     my $hold_data = OpenSRF::Utils::Cache->new('global')->get_cache($cache_key);
3483
3484     if (!$hold_data) {
3485         $logger->info("no hold data found in cache"); # XXX TODO return event
3486         $e->rollback;
3487         return undef;
3488     }
3489
3490     my $maximum = 0;
3491     foreach (keys %$hold_data) {
3492         $maximum += scalar(@{ $hold_data->{$_} });
3493     }
3494     $client->respond({"maximum" => $maximum, "progress" => 0});
3495
3496     for my $action (sort keys %$hold_data) {
3497         while (@{$hold_data->{$action}}) {
3498             my @hid_chunk = splice @{$hold_data->{$action}}, 0, $chunk_size;
3499
3500             my $result_chunk = $e->json_query({
3501                 "select" => {
3502                     "acp" => ["barcode"],
3503                     "au" => [qw/
3504                         first_given_name second_given_name family_name alias
3505                     /],
3506                     "acn" => ["label"],
3507                     "acnp" => [{column => "label", alias => "prefix"}],
3508                     "acns" => [{column => "label", alias => "suffix"}],
3509                     "bre" => ["marc"],
3510                     "acpl" => ["name"],
3511                     "ahr" => ["id"]
3512                 },
3513                 "from" => {
3514                     "ahr" => {
3515                         "acp" => {
3516                             "field" => "id", "fkey" => "current_copy",
3517                             "join" => {
3518                                 "acn" => {
3519                                     "field" => "id", "fkey" => "call_number",
3520                                     "join" => {
3521                                         "bre" => {
3522                                             "field" => "id", "fkey" => "record"
3523                                         },
3524                                         "acnp" => {
3525                                             "field" => "id", "fkey" => "prefix"
3526                                         },
3527                                         "acns" => {
3528                                             "field" => "id", "fkey" => "suffix"
3529                                         }
3530                                     }
3531                                 },
3532                                 "acpl" => {"field" => "id", "fkey" => "location"}
3533                             }
3534                         },
3535                         "au" => {"field" => "id", "fkey" => "usr"}
3536                     }
3537                 },
3538                 "where" => {"+ahr" => {"id" => \@hid_chunk}}
3539             }, {"substream" => 1}) or return $e->die_event;
3540
3541             $client->respond([
3542                 map {
3543                     +{"action" => $action, "hold_details" => $_}
3544                 } @$result_chunk
3545             ]);
3546         }
3547     }
3548
3549     $e->rollback;
3550     return undef;
3551 }
3552
3553
3554 __PACKAGE__->register_method(
3555     method    => 'clear_shelf_process',
3556     stream    => 1,
3557     api_name  => 'open-ils.circ.hold.clear_shelf.process',
3558     signature => {
3559         desc => q/
3560             1. Find all holds that have expired on the holds shelf
3561             2. Cancel the holds
3562             3. If a clear-shelf status is configured, put targeted copies into this status
3563             4. Divide copies into 3 groups: items to transit, items to reshelve, and items
3564                 that are needed for holds.  No subsequent action is taken on the holds
3565                 or items after grouping.
3566         /
3567     }
3568 );
3569
3570 sub clear_shelf_process {
3571     my($self, $client, $auth, $org_id, $match_copy) = @_;
3572
3573     my $e = new_editor(authtoken=>$auth);
3574     $e->checkauth or return $e->die_event;
3575     my $cache = OpenSRF::Utils::Cache->new('global');
3576
3577     $org_id ||= $e->requestor->ws_ou;
3578     $e->allowed('UPDATE_HOLD', $org_id) or return $e->die_event;
3579
3580     my $copy_status = $U->ou_ancestor_setting_value($org_id, 'circ.holds.clear_shelf.copy_status');
3581
3582     my @hold_ids = $self->method_lookup(
3583         "open-ils.circ.captured_holds.id_list.expired_on_shelf.retrieve"
3584     )->run($auth, $org_id, $match_copy);
3585
3586     $e->xact_begin;
3587
3588     my @holds;
3589     my @canceled_holds; # newly canceled holds
3590     my $chunk_size = 25; # chunked status updates
3591     my $counter = 0;
3592     for my $hold_id (@hold_ids) {
3593
3594         $logger->info("Clear shelf processing hold $hold_id");
3595
3596         my $hold = $e->retrieve_action_hold_request([
3597             $hold_id, {
3598                 flesh => 1,
3599                 flesh_fields => {ahr => ['current_copy']}
3600             }
3601         ]);
3602
3603         if (!$hold->cancel_time) { # may be canceled but still on the holds shelf
3604             $hold->cancel_time('now');
3605             $hold->cancel_cause(2); # Hold Shelf expiration
3606             $e->update_action_hold_request($hold) or return $e->die_event;
3607             delete_hold_copy_maps($self, $e, $hold->id) and return $e->die_event;
3608             push(@canceled_holds, $hold_id);
3609         }
3610
3611         my $copy = $hold->current_copy;
3612
3613         if($copy_status or $copy_status == 0) {
3614             # if a clear-shelf copy status is defined, update the copy
3615             $copy->status($copy_status);
3616             $copy->edit_date('now');
3617             $copy->editor($e->requestor->id);
3618             $e->update_asset_copy($copy) or return $e->die_event;
3619         }
3620
3621         push(@holds, $hold);
3622         $client->respond({maximum => scalar(@holds), progress => $counter}) if ( (++$counter % $chunk_size) == 0);
3623     }
3624
3625     if ($e->commit) {
3626
3627         my %cache_data = (
3628             hold => [],
3629             transit => [],
3630             shelf => [],
3631             pl_changed => pickup_lib_changed_on_shelf_holds($e, $org_id, \@hold_ids)
3632         );
3633
3634         for my $hold (@holds) {
3635
3636             my $copy = $hold->current_copy;
3637             my ($alt_hold) = __PACKAGE__->find_nearest_permitted_hold($e, $copy, $e->requestor, 1);
3638
3639             if($alt_hold and !$match_copy) {
3640
3641                 push(@{$cache_data{hold}}, $hold->id); # copy is needed for a hold
3642
3643             } elsif($copy->circ_lib != $e->requestor->ws_ou) {
3644
3645                 push(@{$cache_data{transit}}, $hold->id); # copy needs to transit
3646
3647             } else {
3648
3649                 push(@{$cache_data{shelf}}, $hold->id); # copy needs to go back to the shelf
3650             }
3651         }
3652
3653         my $cache_key = md5_hex(time . $$ . rand());
3654         $logger->info("clear_shelf_cache: storing under $cache_key");
3655         $cache->put_cache($cache_key, \%cache_data, 7200); # TODO: 2 hours.  configurable?
3656
3657         # tell the client we're done
3658         $client->respond_complete({cache_key => $cache_key});
3659
3660         # ------------
3661         # fire off the hold cancelation trigger and wait for response so don't flood the service
3662
3663         # refetch the holds to pick up the caclulated cancel_time,
3664         # which may be needed by Action/Trigger
3665         $e->xact_begin;
3666         my $updated_holds = [];
3667         $updated_holds = $e->search_action_hold_request({id => \@canceled_holds}, {substream => 1}) if (@canceled_holds > 0);
3668         $e->rollback;
3669
3670         $U->create_events_for_hook(
3671             'hold_request.cancel.expire_holds_shelf',
3672             $_, $org_id, undef, undef, 1) for @$updated_holds;
3673
3674     } else {
3675         # tell the client we're done
3676         $client->respond_complete;
3677     }
3678 }
3679
3680 # returns IDs for holds that are on the holds shelf but 
3681 # have had their pickup_libs change while on the shelf.
3682 sub pickup_lib_changed_on_shelf_holds {
3683     my $e = shift;
3684     my $org_id = shift;
3685     my $ignore_holds = shift;
3686     $ignore_holds = [$ignore_holds] if !ref($ignore_holds);
3687
3688     my $query = {
3689         select => { alhr => ['id'] },
3690         from   => {
3691             alhr => {
3692                 acp => {
3693                     field => 'id',
3694                     fkey  => 'current_copy'
3695                 },
3696             }
3697         },
3698         where => {
3699             '+acp' => { status => OILS_COPY_STATUS_ON_HOLDS_SHELF },
3700             '+alhr' => {
3701                 capture_time     => { "!=" => undef },
3702                 fulfillment_time => undef,
3703                 current_shelf_lib => $org_id,
3704                 pickup_lib => {'!='  => {'+alhr' => 'current_shelf_lib'}}
3705             }
3706         }
3707     };
3708
3709     $query->{where}->{'+alhr'}->{id} =
3710         {'not in' => $ignore_holds} if @$ignore_holds;
3711
3712     my $hold_ids = $e->json_query($query);
3713     return [ map { $_->{id} } @$hold_ids ];
3714 }
3715
3716 __PACKAGE__->register_method(
3717     method    => 'usr_hold_summary',
3718     api_name  => 'open-ils.circ.holds.user_summary',
3719     signature => q/
3720         Returns a summary of holds statuses for a given user
3721     /
3722 );
3723
3724 sub usr_hold_summary {
3725     my($self, $conn, $auth, $user_id) = @_;
3726
3727     my $e = new_editor(authtoken=>$auth);
3728     $e->checkauth or return $e->event;
3729     $e->allowed('VIEW_HOLD') or return $e->event;
3730
3731     my $holds = $e->search_action_hold_request(
3732         {
3733             usr =>  $user_id ,
3734             fulfillment_time => undef,
3735             cancel_time      => undef,
3736         }
3737     );
3738
3739     my %summary = (1 => 0, 2 => 0, 3 => 0, 4 => 0);
3740     $summary{_hold_status($e, $_)} += 1 for @$holds;
3741     return \%summary;
3742 }
3743
3744
3745
3746 __PACKAGE__->register_method(
3747     method    => 'hold_has_copy_at',
3748     api_name  => 'open-ils.circ.hold.has_copy_at',
3749     signature => {
3750         desc   =>
3751                 'Returns the ID of the found copy and name of the shelving location if there is ' .
3752                 'an available copy at the specified org unit.  Returns empty hash otherwise.  '   .
3753                 'The anticipated use for this method is to determine whether an item is '         .
3754                 'available at the library where the user is placing the hold (or, alternatively, '.
3755                 'at the pickup library) to encourage bypassing the hold placement and just '      .
3756                 'checking out the item.' ,
3757         params => [
3758             { desc => 'Authentication Token', type => 'string' },
3759             { desc => 'Method Arguments.  Options include: hold_type, hold_target, org_unit.  '
3760                     . 'hold_type is the hold type code (T, V, C, M, ...).  '
3761                     . 'hold_target is the identifier of the hold target object.  '
3762                     . 'org_unit is org unit ID.',
3763               type => 'object'
3764             }
3765         ],
3766         return => {
3767             desc => q/Result hash like { "copy" : copy_id, "location" : location_name }, empty hash on misses, event on error./,
3768             type => 'object'
3769         }
3770     }
3771 );
3772
3773 sub hold_has_copy_at {
3774     my($self, $conn, $auth, $args) = @_;
3775
3776     my $e = new_editor(authtoken=>$auth);
3777     $e->checkauth or return $e->event;
3778
3779     my $hold_type   = $$args{hold_type};
3780     my $hold_target = $$args{hold_target};
3781     my $org_unit    = $$args{org_unit};
3782
3783     my $query = {
3784         select => {acp => ['id'], acpl => ['name']},
3785         from   => {
3786             acp => {
3787                 acpl => {field => 'id', filter => { holdable => 't'}, fkey => 'location'},
3788                 ccs  => {field => 'id', filter => { holdable => 't'}, fkey => 'status'  }
3789             }
3790         },
3791         where => {'+acp' => { circulate => 't', deleted => 'f', holdable => 't', circ_lib => $org_unit, status => [0,7]}},
3792         limit => 1
3793     };
3794
3795     if($hold_type eq 'C' or $hold_type eq 'F' or $hold_type eq 'R') {
3796
3797         $query->{where}->{'+acp'}->{id} = $hold_target;
3798
3799     } elsif($hold_type eq 'V') {
3800
3801         $query->{where}->{'+acp'}->{call_number} = $hold_target;
3802
3803     } elsif($hold_type eq 'P') {
3804
3805         $query->{from}->{acp}->{acpm} = {
3806             field  => 'target_copy',
3807             fkey   => 'id',
3808             filter => {part => $hold_target},
3809         };
3810
3811     } elsif($hold_type eq 'I') {
3812
3813         $query->{from}->{acp}->{sitem} = {
3814             field  => 'unit',
3815             fkey   => 'id',
3816             filter => {issuance => $hold_target},
3817         };
3818
3819     } elsif($hold_type eq 'T') {
3820
3821         $query->{from}->{acp}->{acn} = {
3822             field  => 'id',
3823             fkey   => 'call_number',
3824             'join' => {
3825                 bre => {
3826                     field  => 'id',
3827                     filter => {id => $hold_target},
3828                     fkey   => 'record'
3829                 }
3830             }
3831         };
3832
3833     } else {
3834
3835         $query->{from}->{acp}->{acn} = {
3836             field => 'id',
3837             fkey  => 'call_number',
3838             join  => {
3839                 bre => {
3840                     field => 'id',
3841                     fkey  => 'record',
3842                     join  => {
3843                         mmrsm => {
3844                             field  => 'source',
3845                             fkey   => 'id',
3846                             filter => {metarecord => $hold_target},
3847                         }
3848                     }
3849                 }
3850             }
3851         };
3852     }
3853
3854     my $res = $e->json_query($query)->[0] or return {};
3855     return {copy => $res->{id}, location => $res->{name}} if $res;
3856 }
3857
3858
3859 # returns true if the user already has an item checked out
3860 # that could be used to fulfill the requested hold.
3861 sub hold_item_is_checked_out {
3862     my($e, $user_id, $hold_type, $hold_target) = @_;
3863
3864     my $query = {
3865         select => {acp => ['id']},
3866         from   => {acp => {}},
3867         where  => {
3868             '+acp' => {
3869                 id => {
3870                     in => { # copies for circs the user has checked out
3871                         select => {circ => ['target_copy']},
3872                         from   => 'circ',
3873                         where  => {
3874                             usr => $user_id,
3875                             checkin_time => undef,
3876                             '-or' => [
3877                                 {stop_fines => ["MAXFINES","LONGOVERDUE"]},
3878                                 {stop_fines => undef}
3879                             ],
3880                         }
3881                     }
3882                 }
3883             }
3884         },
3885         limit => 1
3886     };
3887
3888     if($hold_type eq 'C' || $hold_type eq 'R' || $hold_type eq 'F') {
3889
3890         $query->{where}->{'+acp'}->{id}->{in}->{where}->{'target_copy'} = $hold_target;
3891
3892     } elsif($hold_type eq 'V') {
3893
3894         $query->{where}->{'+acp'}->{call_number} = $hold_target;
3895
3896      } elsif($hold_type eq 'P') {
3897
3898         $query->{from}->{acp}->{acpm} = {
3899             field  => 'target_copy',
3900             fkey   => 'id',
3901             filter => {part => $hold_target},
3902         };
3903
3904      } elsif($hold_type eq 'I') {
3905
3906         $query->{from}->{acp}->{sitem} = {
3907             field  => 'unit',
3908             fkey   => 'id',
3909             filter => {issuance => $hold_target},
3910         };
3911
3912     } elsif($hold_type eq 'T') {
3913
3914         $query->{from}->{acp}->{acn} = {
3915             field  => 'id',
3916             fkey   => 'call_number',
3917             'join' => {
3918                 bre => {
3919                     field  => 'id',
3920                     filter => {id => $hold_target},
3921                     fkey   => 'record'
3922                 }
3923             }
3924         };
3925
3926     } else {
3927
3928         $query->{from}->{acp}->{acn} = {
3929             field => 'id',
3930             fkey => 'call_number',
3931             join => {
3932                 bre => {
3933                     field => 'id',
3934                     fkey => 'record',
3935                     join => {
3936                         mmrsm => {
3937                             field => 'source',
3938                             fkey => 'id',
3939                             filter => {metarecord => $hold_target},
3940                         }
3941                     }
3942                 }
3943             }
3944         };
3945     }
3946
3947     return $e->json_query($query)->[0];
3948 }
3949
3950 __PACKAGE__->register_method(
3951     method    => 'change_hold_title',
3952     api_name  => 'open-ils.circ.hold.change_title',
3953     signature => {
3954         desc => q/
3955             Updates all title level holds targeting the specified bibs to point a new bib./,
3956         params => [
3957             { desc => 'Authentication Token', type => 'string' },
3958             { desc => 'New Target Bib Id',    type => 'number' },
3959             { desc => 'Old Target Bib Ids',   type => 'array'  },
3960         ],
3961         return => { desc => '1 on success' }
3962     }
3963 );
3964
3965 __PACKAGE__->register_method(
3966     method    => 'change_hold_title_for_specific_holds',
3967     api_name  => 'open-ils.circ.hold.change_title.specific_holds',
3968     signature => {
3969         desc => q/
3970             Updates specified holds to target new bib./,
3971         params => [
3972             { desc => 'Authentication Token', type => 'string' },
3973             { desc => 'New Target Bib Id',    type => 'number' },
3974             { desc => 'Holds Ids for holds to update',   type => 'array'  },
3975         ],
3976         return => { desc => '1 on success' }
3977     }
3978 );
3979
3980
3981 sub change_hold_title {
3982     my( $self, $client, $auth, $new_bib_id, $bib_ids ) = @_;
3983
3984     my $e = new_editor(authtoken=>$auth, xact=>1);
3985     return $e->die_event unless $e->checkauth;
3986
3987     my $holds = $e->search_action_hold_request(
3988         [
3989             {
3990                 cancel_time      => undef,
3991                 fulfillment_time => undef,
3992                 hold_type        => 'T',
3993                 target           => $bib_ids
3994             },
3995             {
3996                 flesh        => 1,
3997                 flesh_fields => { ahr => ['usr'] }
3998             }
3999         ],
4000         { substream => 1 }
4001     );
4002
4003     for my $hold (@$holds) {
4004         $e->allowed('UPDATE_HOLD', $hold->usr->home_ou) or return $e->die_event;
4005         $logger->info("Changing hold " . $hold->id . " target from " . $hold->target . " to $new_bib_id in title hold target change");
4006         $hold->target( $new_bib_id );
4007         $e->update_action_hold_request($hold) or return $e->die_event;
4008     }
4009
4010     $e->commit;
4011
4012     _reset_hold($self, $e->requestor, $_) for @$holds;
4013
4014     return 1;
4015 }
4016
4017 sub change_hold_title_for_specific_holds {
4018     my( $self, $client, $auth, $new_bib_id, $hold_ids ) = @_;
4019
4020     my $e = new_editor(authtoken=>$auth, xact=>1);
4021     return $e->die_event unless $e->checkauth;
4022
4023     my $holds = $e->search_action_hold_request(
4024         [
4025             {
4026                 cancel_time      => undef,
4027                 fulfillment_time => undef,
4028                 hold_type        => 'T',
4029                 id               => $hold_ids
4030             },
4031             {
4032                 flesh        => 1,
4033                 flesh_fields => { ahr => ['usr'] }
4034             }
4035         ],
4036         { substream => 1 }
4037     );
4038
4039     for my $hold (@$holds) {
4040         $e->allowed('UPDATE_HOLD', $hold->usr->home_ou) or return $e->die_event;
4041         $logger->info("Changing hold " . $hold->id . " target from " . $hold->target . " to $new_bib_id in title hold target change");
4042         $hold->target( $new_bib_id );
4043         $e->update_action_hold_request($hold) or return $e->die_event;
4044     }
4045
4046     $e->commit;
4047
4048     _reset_hold($self, $e->requestor, $_) for @$holds;
4049
4050     return 1;
4051 }
4052
4053 __PACKAGE__->register_method(
4054     method    => 'rec_hold_count',
4055     api_name  => 'open-ils.circ.bre.holds.count',
4056     signature => {
4057         desc => q/Returns the total number of holds that target the
4058             selected bib record or its associated copies and call_numbers/,
4059         params => [
4060             { desc => 'Bib ID', type => 'number' },
4061             { desc => q/Optional arguments.  Supported arguments include:
4062                 "pickup_lib_descendant" -> limit holds to those whose pickup
4063                 library is equal to or is a child of the provided org unit/,
4064                 type => 'object'
4065             }
4066         ],
4067         return => {desc => 'Hold count', type => 'number'}
4068     }
4069 );
4070
4071 __PACKAGE__->register_method(
4072     method    => 'rec_hold_count',
4073     api_name  => 'open-ils.circ.mmr.holds.count',
4074     signature => {
4075         desc => q/Returns the total number of holds that target the
4076             selected metarecord or its associated copies, call_numbers, and bib records/,
4077         params => [
4078             { desc => 'Metarecord ID', type => 'number' },
4079         ],
4080         return => {desc => 'Hold count', type => 'number'}
4081     }
4082 );
4083
4084 # XXX Need to add type I (and, soon, type P) holds to these counts
4085 sub rec_hold_count {
4086     my($self, $conn, $target_id, $args) = @_;
4087     $args ||= {};
4088
4089     my $mmr_join = {
4090         mmrsm => {
4091             field => 'id',
4092             fkey => 'source',
4093             filter => {metarecord => $target_id}
4094         }
4095     };
4096
4097     my $bre_join = {
4098         bre => {
4099             field => 'id',
4100             filter => { id => $target_id },
4101             fkey => 'record'
4102         }
4103     };
4104
4105     if($self->api_name =~ /mmr/) {
4106         delete $bre_join->{bre}->{filter};
4107         $bre_join->{bre}->{join} = $mmr_join;
4108     }
4109
4110     my $cn_join = {
4111         acn => {
4112             field => 'id',
4113             fkey => 'call_number',
4114             join => $bre_join
4115         }
4116     };
4117
4118     my $query = {
4119         select => {ahr => [{column => 'id', transform => 'count', alias => 'count'}]},
4120         from => 'ahr',
4121         where => {
4122             '+ahr' => {
4123                 cancel_time => undef,
4124                 fulfillment_time => undef,
4125                 '-or' => [
4126                     {
4127                         '-and' => {
4128                             hold_type => [qw/C F R/],
4129                             target => {
4130                                 in => {
4131                                     select => {acp => ['id']},
4132                                     from => { acp => $cn_join }
4133                                 }
4134                             }
4135                         }
4136                     },
4137                     {
4138                         '-and' => {
4139                             hold_type => 'V',
4140                             target => {
4141                                 in => {
4142                                     select => {acn => ['id']},
4143                                     from => {acn => $bre_join}
4144                                 }
4145                             }
4146                         }
4147                     },
4148                     {
4149                         '-and' => {
4150                             hold_type => 'T',
4151                             target => $target_id
4152                         }
4153                     }
4154                 ]
4155             }
4156         }
4157     };
4158
4159     if($self->api_name =~ /mmr/) {
4160         $query->{where}->{'+ahr'}->{'-or'}->[2] = {
4161             '-and' => {
4162                 hold_type => 'T',
4163                 target => {
4164                     in => {
4165                         select => {bre => ['id']},
4166                         from => {bre => $mmr_join}
4167                     }
4168                 }
4169             }
4170         };
4171
4172         $query->{where}->{'+ahr'}->{'-or'}->[3] = {
4173             '-and' => {
4174                 hold_type => 'M',
4175                 target => $target_id
4176             }
4177         };
4178     }
4179
4180
4181     if (my $pld = $args->{pickup_lib_descendant}) {
4182
4183         my $top_ou = new_editor()->search_actor_org_unit(
4184             {parent_ou => undef}
4185         )->[0]; # XXX Assumes single root node. Not alone in this...
4186
4187         $query->{where}->{'+ahr'}->{pickup_lib} = {
4188             in => {
4189                 select  => {aou => [{ 
4190                     column => 'id', 
4191                     transform => 'actor.org_unit_descendants', 
4192                     result_field => 'id' 
4193                 }]},
4194                 from    => 'aou',
4195                 where   => {id => $pld}
4196             }
4197         } if ($pld != $top_ou->id);
4198     }
4199
4200
4201     return new_editor()->json_query($query)->[0]->{count};
4202 }
4203
4204 # A helper function to calculate a hold's expiration time at a given
4205 # org_unit. Takes the org_unit as an argument and returns either the
4206 # hold expire time as an ISO8601 string or undef if there is no hold
4207 # expiration interval set for the subject ou.
4208 sub calculate_expire_time
4209 {
4210     my $ou = shift;
4211     my $interval = $U->ou_ancestor_setting_value($ou, OILS_SETTING_HOLD_EXPIRE);
4212     if($interval) {
4213         my $date = DateTime->now->add(seconds => OpenSRF::Utils::interval_to_seconds($interval));
4214         return $U->epoch2ISO8601($date->epoch);
4215     }
4216     return undef;
4217 }
4218
4219
4220 __PACKAGE__->register_method(
4221     method    => 'mr_hold_filter_attrs',
4222     api_name  => 'open-ils.circ.mmr.holds.filters',
4223     authoritative => 1,
4224     stream => 1,
4225     signature => {
4226         desc => q/
4227             Returns the set of available formats and languages for the
4228             constituent records of the provided metarcord.
4229             If an array of hold IDs is also provided, information about
4230             each is returned as well.  This information includes:
4231             1. a slightly easier to read version of holdable_formats
4232             2. attributes describing the set of format icons included
4233                in the set of desired, constituent records.
4234         /,
4235         params => [
4236             {desc => 'Metarecord ID', type => 'number'},
4237             {desc => 'Context Org ID', type => 'number'},
4238             {desc => 'Hold ID List', type => 'array'},
4239         ],
4240         return => {
4241             desc => q/
4242                 Stream of objects.  The first will have a 'metarecord' key
4243                 containing non-hold-specific metarecord information, subsequent
4244                 responses will contain a 'hold' key containing hold-specific
4245                 information
4246             /, 
4247             type => 'object'
4248         }
4249     }
4250 );
4251
4252 sub mr_hold_filter_attrs { 
4253     my ($self, $client, $mr_id, $org_id, $hold_ids) = @_;
4254     my $e = new_editor();
4255
4256     # by default, return MR / hold attributes for all constituent
4257     # records with holdable copies.  If there is a hard boundary,
4258     # though, limit to records with copies within the boundary,
4259     # since anything outside the boundary can never be held.
4260     my $org_depth = 0;
4261     if ($org_id) {
4262         $org_depth = $U->ou_ancestor_setting_value(
4263             $org_id, OILS_SETTING_HOLD_HARD_BOUNDARY) || 0;
4264     }
4265
4266     # get all org-scoped records w/ holdable copies for this metarecord
4267     my ($bre_ids) = $self->method_lookup(
4268         'open-ils.circ.holds.metarecord.filtered_records')->run(
4269             $mr_id, undef, $org_id, $org_depth);
4270
4271     my $item_lang_attr = 'item_lang'; # configurable?
4272     my $format_attr = $e->retrieve_config_global_flag(
4273         'opac.metarecord.holds.format_attr')->value;
4274
4275     # helper sub for fetching ccvms for a batch of record IDs
4276     sub get_batch_ccvms {
4277         my ($e, $attr, $bre_ids) = @_;
4278         return [] unless $bre_ids and @$bre_ids;
4279         my $vals = $e->search_metabib_record_attr_flat({
4280             attr => $attr,
4281             id => $bre_ids
4282         });
4283         return [] unless @$vals;
4284         return $e->search_config_coded_value_map({
4285             ctype => $attr,
4286             code => [map {$_->value} @$vals]
4287         });
4288     }
4289
4290     my $langs = get_batch_ccvms($e, $item_lang_attr, $bre_ids);
4291     my $formats = get_batch_ccvms($e, $format_attr, $bre_ids);
4292
4293     $client->respond({
4294         metarecord => {
4295             id => $mr_id,
4296             formats => $formats,
4297             langs => $langs
4298         }
4299     });
4300
4301     return unless $hold_ids;
4302     my $icon_attr = $e->retrieve_config_global_flag('opac.icon_attr');
4303     $icon_attr = $icon_attr ? $icon_attr->value : '';
4304
4305     for my $hold_id (@$hold_ids) {
4306         my $hold = $e->retrieve_action_hold_request($hold_id) 
4307             or return $e->event;
4308
4309         next unless $hold->hold_type eq 'M';
4310
4311         my $resp = {
4312             hold => {
4313                 id => $hold_id,
4314                 formats => [],
4315                 langs => []
4316             }
4317         };
4318
4319         # collect the ccvm's for the selected formats / language
4320         # (i.e. the holdable formats) on the MR.
4321         # this assumes a two-key structure for format / language,
4322         # though no assumption is made about the keys themselves.
4323         my $hformats = OpenSRF::Utils::JSON->JSON2perl($hold->holdable_formats);
4324         my $lang_vals = [];
4325         my $format_vals = [];
4326         for my $val (values %$hformats) {
4327             # val is either a single ccvm or an array of them
4328             $val = [$val] unless ref $val eq 'ARRAY';
4329             for my $node (@$val) {
4330                 push (@$lang_vals, $node->{_val})   
4331                     if $node->{_attr} eq $item_lang_attr; 
4332                 push (@$format_vals, $node->{_val})   
4333                     if $node->{_attr} eq $format_attr;
4334             }
4335         }
4336
4337         # fetch the ccvm's for consistency with the {metarecord} blob
4338         $resp->{hold}{formats} = $e->search_config_coded_value_map({
4339             ctype => $format_attr, code => $format_vals});
4340         $resp->{hold}{langs} = $e->search_config_coded_value_map({
4341             ctype => $item_lang_attr, code => $lang_vals});
4342
4343         # find all of the bib records within this metarcord whose 
4344         # format / language match the holdable formats on the hold
4345         my ($bre_ids) = $self->method_lookup(
4346             'open-ils.circ.holds.metarecord.filtered_records')->run(
4347                 $hold->target, $hold->holdable_formats, 
4348                 $hold->selection_ou, $hold->selection_depth);
4349
4350         # now find all of the 'icon' attributes for the records
4351         $resp->{hold}{icons} = get_batch_ccvms($e, $icon_attr, $bre_ids);
4352         $client->respond($resp);
4353     }
4354
4355     return;
4356 }
4357
4358 1;