LP1182519 Per-Hold Behind Desk Value
[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     foreach (@$target_list) {
96         $$params{$target_field} = $_;
97         my $res;
98         ($res) = $self->method_lookup(
99             'open-ils.circ.title_hold.is_possible')->run($auth, $params, $override ? $oargs : {});
100         if ($res->{'success'} == 1) {
101
102             $params->{'depth'} = $res->{'depth'} if $res->{'depth'};
103
104             # Remove oargs from params so holds can be created.
105             if ($$params{oargs}) {
106                 delete $$params{oargs};
107             }
108
109             my $ahr = construct_hold_request_object($params);
110             my ($res2) = $self->method_lookup(
111                 $override
112                 ? 'open-ils.circ.holds.create.override'
113                 : 'open-ils.circ.holds.create'
114             )->run($auth, $ahr, $oargs);
115             $res2 = {
116                 'target' => $$params{$target_field},
117                 'result' => $res2
118             };
119             $conn->respond($res2);
120         } else {
121             $res = {
122                 'target' => $$params{$target_field},
123                 'result' => $res
124             };
125             $conn->respond($res);
126         }
127     }
128     return undef;
129 }
130
131 sub construct_hold_request_object {
132     my ($params) = @_;
133
134     my $ahr = Fieldmapper::action::hold_request->new;
135     $ahr->isnew('1');
136
137     foreach my $field (keys %{ $params }) {
138         if ($field eq 'depth') { $ahr->selection_depth($$params{$field}); }
139         elsif ($field eq 'patronid') {
140             $ahr->usr($$params{$field}); }
141         elsif ($field eq 'titleid') { $ahr->target($$params{$field}); }
142         elsif ($field eq 'copy_id') { $ahr->target($$params{$field}); }
143         elsif ($field eq 'issuanceid') { $ahr->target($$params{$field}); }
144         elsif ($field eq 'volume_id') { $ahr->target($$params{$field}); }
145         elsif ($field eq 'mrid') { $ahr->target($$params{$field}); }
146         elsif ($field eq 'partid') { $ahr->target($$params{$field}); }
147         else {
148             $ahr->$field($$params{$field});
149         }
150     }
151     return $ahr;
152 }
153
154 __PACKAGE__->register_method(
155     method    => "create_hold_batch",
156     api_name  => "open-ils.circ.holds.create.batch",
157     stream => 1,
158     signature => {
159         desc => q/@see open-ils.circ.holds.create.batch/,
160         params => [
161             { desc => 'Authentication token', type => 'string' },
162             { desc => 'Array of hold objects', type => 'array' }
163         ],
164         return => {
165             desc => 'Array of hold ID on success, -1 on missing arg, event (or ref to array of events) on error(s)',
166         },
167     }
168 );
169
170 __PACKAGE__->register_method(
171     method    => "create_hold_batch",
172     api_name  => "open-ils.circ.holds.create.override.batch",
173     stream => 1,
174     signature => {
175         desc  => '@see open-ils.circ.holds.create.batch',
176     }
177 );
178
179
180 sub create_hold_batch {
181     my( $self, $conn, $auth, $hold_list, $oargs ) = @_;
182     (my $method = $self->api_name) =~ s/\.batch//og;
183     foreach (@$hold_list) {
184         my ($res) = $self->method_lookup($method)->run($auth, $_, $oargs);
185         $conn->respond($res);
186     }
187     return undef;
188 }
189
190
191 __PACKAGE__->register_method(
192     method    => "create_hold",
193     api_name  => "open-ils.circ.holds.create",
194     signature => {
195         desc => "Create a new hold for an item.  From a permissions perspective, " .
196                 "the login session is used as the 'requestor' of the hold.  "      .
197                 "The hold recipient is determined by the 'usr' setting within the hold object. " .
198                 'First we verify the requestor has holds request permissions.  '         .
199                 'Then we verify that the recipient is allowed to make the given hold.  ' .
200                 'If not, we see if the requestor has "override" capabilities.  If not, ' .
201                 'a permission exception is returned.  If permissions allow, we cycle '   .
202                 'through the set of holds objects and create.  '                         .
203                 'If the recipient does not have permission to place multiple holds '     .
204                 'on a single title and said operation is attempted, a permission '       .
205                 'exception is returned',
206         params => [
207             { desc => 'Authentication token',               type => 'string' },
208             { desc => 'Hold object for hold to be created',
209                 type => 'object', class => 'ahr' }
210         ],
211         return => {
212             desc => 'New ahr ID on success, -1 on missing arg, event (or ref to array of events) on error(s)',
213         },
214     }
215 );
216
217 __PACKAGE__->register_method(
218     method    => "create_hold",
219     api_name  => "open-ils.circ.holds.create.override",
220     notes     => '@see open-ils.circ.holds.create',
221     signature => {
222         desc  => "If the recipient is not allowed to receive the requested hold, " .
223                  "call this method to attempt the override",
224         params => [
225             { desc => 'Authentication token',               type => 'string' },
226             {
227                 desc => 'Hold object for hold to be created',
228                 type => 'object', class => 'ahr'
229             }
230         ],
231         return => {
232             desc => 'New hold (ahr) ID on success, -1 on missing arg, event (or ref to array of events) on error(s)',
233         },
234     }
235 );
236
237 sub create_hold {
238     my( $self, $conn, $auth, $hold, $oargs ) = @_;
239     return -1 unless $hold;
240     my $e = new_editor(authtoken=>$auth, xact=>1);
241     return $e->die_event unless $e->checkauth;
242
243     my $override = 0;
244     if ($self->api_name =~ /override/) {
245         $override = 1;
246         $oargs = { all => 1 } unless defined $oargs;
247     }
248
249     my @events;
250
251     my $requestor = $e->requestor;
252     my $recipient = $requestor;
253
254     if( $requestor->id ne $hold->usr ) {
255         # Make sure the requestor is allowed to place holds for
256         # the recipient if they are not the same people
257         $recipient = $e->retrieve_actor_user($hold->usr)  or return $e->die_event;
258         $e->allowed('REQUEST_HOLDS', $recipient->home_ou) or return $e->die_event;
259     }
260
261     # If the related org setting tells us to, block if patron privs have expired
262     my $expire_setting = $U->ou_ancestor_setting_value($recipient->home_ou, OILS_SETTING_BLOCK_HOLD_FOR_EXPIRED_PATRON);
263     if ($expire_setting) {
264         my $expire = DateTime::Format::ISO8601->new->parse_datetime(
265             cleanse_ISO8601($recipient->expire_date));
266
267         push( @events, OpenILS::Event->new(
268             'PATRON_ACCOUNT_EXPIRED',
269             "payload" => {"fail_part" => "actor.usr.privs_expired"}
270             )) if( CORE::time > $expire->epoch ) ;
271     }
272
273     # Now make sure the recipient is allowed to receive the specified hold
274     my $porg = $recipient->home_ou;
275     my $rid  = $e->requestor->id;
276     my $t    = $hold->hold_type;
277
278     # See if a duplicate hold already exists
279     my $sargs = {
280         usr              => $recipient->id,
281         hold_type        => $t,
282         fulfillment_time => undef,
283         target           => $hold->target,
284         cancel_time      => undef,
285     };
286
287     $sargs->{holdable_formats} = $hold->holdable_formats if $t eq 'M';
288
289     my $existing = $e->search_action_hold_request($sargs);
290     push( @events, OpenILS::Event->new('HOLD_EXISTS')) if @$existing;
291
292     my $checked_out = hold_item_is_checked_out($e, $recipient->id, $hold->hold_type, $hold->target);
293     push( @events, OpenILS::Event->new('HOLD_ITEM_CHECKED_OUT')) if $checked_out;
294
295     if ( $t eq OILS_HOLD_TYPE_METARECORD ) {
296         return $e->die_event unless $e->allowed('MR_HOLDS',     $porg);
297     } elsif ( $t eq OILS_HOLD_TYPE_TITLE ) {
298         return $e->die_event unless $e->allowed('TITLE_HOLDS',  $porg);
299     } elsif ( $t eq OILS_HOLD_TYPE_VOLUME ) {
300         return $e->die_event unless $e->allowed('VOLUME_HOLDS', $porg);
301     } elsif ( $t eq OILS_HOLD_TYPE_MONOPART ) {
302         return $e->die_event unless $e->allowed('TITLE_HOLDS', $porg);
303     } elsif ( $t eq OILS_HOLD_TYPE_ISSUANCE ) {
304         return $e->die_event unless $e->allowed('ISSUANCE_HOLDS', $porg);
305     } elsif ( $t eq OILS_HOLD_TYPE_COPY ) {
306         return $e->die_event unless $e->allowed('COPY_HOLDS',   $porg);
307     } elsif ( $t eq OILS_HOLD_TYPE_FORCE || $t eq OILS_HOLD_TYPE_RECALL ) {
308         my $copy = $e->retrieve_asset_copy($hold->target)
309             or return $e->die_event;
310         if ( $t eq OILS_HOLD_TYPE_FORCE ) {
311             return $e->die_event unless $e->allowed('COPY_HOLDS_FORCE',   $copy->circ_lib);
312         } elsif ( $t eq OILS_HOLD_TYPE_RECALL ) {
313             return $e->die_event unless $e->allowed('COPY_HOLDS_RECALL',   $copy->circ_lib);
314         }
315     }
316
317     if( @events ) {
318         if (!$override) {
319             $e->rollback;
320             return \@events;
321         }
322         for my $evt (@events) {
323             next unless $evt;
324             my $name = $evt->{textcode};
325             if($oargs->{all} || grep { $_ eq $name } @{$oargs->{events}}) {
326                 return $e->die_event unless $e->allowed("$name.override", $porg);
327             } else {
328                 $e->rollback;
329                 return \@events;
330             }
331         }
332     }
333
334         # Check for hold expiration in the past, and set it to empty string.
335         $hold->expire_time(undef) if ($hold->expire_time && $U->datecmp($hold->expire_time) == -1);
336
337     # set the configured expire time
338     unless($hold->expire_time) {
339         $hold->expire_time(calculate_expire_time($recipient->home_ou));
340     }
341
342
343     # if behind-the-desk pickup is supported at the hold pickup lib,
344     # set the value to the patron default, unless a value has already
345     # been applied.  If it's not supported, force the value to false.
346
347     my $bdous = $U->ou_ancestor_setting_value(
348         $hold->pickup_lib, 
349         'circ.holds.behind_desk_pickup_supported', $e);
350
351     if ($bdous) {
352         if (!defined $hold->behind_desk) {
353
354             my $set = $e->search_actor_user_setting({
355                 usr => $hold->usr, 
356                 name => 'circ.holds_behind_desk'
357             })->[0];
358         
359             $hold->behind_desk('t') if $set and 
360                 OpenSRF::Utils::JSON->JSON2perl($set->value);
361         }
362     } else {
363         # behind the desk not supported, force it to false
364         $hold->behind_desk('f');
365     }
366
367     $hold->requestor($e->requestor->id);
368     $hold->request_lib($e->requestor->ws_ou);
369     $hold->selection_ou($hold->pickup_lib) unless $hold->selection_ou;
370     $hold = $e->create_action_hold_request($hold) or return $e->die_event;
371
372     $e->commit;
373
374     $conn->respond_complete($hold->id);
375
376     $U->storagereq(
377         'open-ils.storage.action.hold_request.copy_targeter',
378         undef, $hold->id ) unless $U->is_true($hold->frozen);
379
380     return undef;
381 }
382
383 # makes sure that a user has permission to place the type of requested hold
384 # returns the Perm exception if not allowed, returns undef if all is well
385 sub _check_holds_perm {
386     my($type, $user_id, $org_id) = @_;
387
388     my $evt;
389     if ($type eq "M") {
390         $evt = $apputils->check_perms($user_id, $org_id, "MR_HOLDS"    );
391     } elsif ($type eq "T") {
392         $evt = $apputils->check_perms($user_id, $org_id, "TITLE_HOLDS" );
393     } elsif($type eq "V") {
394         $evt = $apputils->check_perms($user_id, $org_id, "VOLUME_HOLDS");
395     } elsif($type eq "C") {
396         $evt = $apputils->check_perms($user_id, $org_id, "COPY_HOLDS"  );
397     }
398
399     return $evt if $evt;
400     return undef;
401 }
402
403 # tests if the given user is allowed to place holds on another's behalf
404 sub _check_request_holds_perm {
405     my $user_id = shift;
406     my $org_id  = shift;
407     if (my $evt = $apputils->check_perms(
408         $user_id, $org_id, "REQUEST_HOLDS")) {
409         return $evt;
410     }
411 }
412
413 my $ses_is_req_note = 'The login session is the requestor.  If the requestor is different from the user, ' .
414                       'then the requestor must have VIEW_HOLD permissions';
415
416 __PACKAGE__->register_method(
417     method    => "retrieve_holds_by_id",
418     api_name  => "open-ils.circ.holds.retrieve_by_id",
419     signature => {
420         desc   => "Retrieve the hold, with hold transits attached, for the specified ID.  $ses_is_req_note",
421         params => [
422             { desc => 'Authentication token', type => 'string' },
423             { desc => 'Hold ID',              type => 'number' }
424         ],
425         return => {
426             desc => 'Hold object with transits attached, event on error',
427         }
428     }
429 );
430
431
432 sub retrieve_holds_by_id {
433     my($self, $client, $auth, $hold_id) = @_;
434     my $e = new_editor(authtoken=>$auth);
435     $e->checkauth or return $e->event;
436     $e->allowed('VIEW_HOLD') or return $e->event;
437
438     my $holds = $e->search_action_hold_request(
439         [
440             { id =>  $hold_id , fulfillment_time => undef },
441             {
442                 order_by => { ahr => "request_time" },
443                 flesh => 1,
444                 flesh_fields => {ahr => ['notes']}
445             }
446         ]
447     );
448
449     flesh_hold_transits($holds);
450     flesh_hold_notices($holds, $e);
451     return $holds;
452 }
453
454
455 __PACKAGE__->register_method(
456     method    => "retrieve_holds",
457     api_name  => "open-ils.circ.holds.retrieve",
458     signature => {
459         desc   => "Retrieves all the holds, with hold transits attached, for the specified user.  $ses_is_req_note",
460         params => [
461             { desc => 'Authentication token', type => 'string'  },
462             { desc => 'User ID',              type => 'integer' },
463             { desc => 'Available Only',       type => 'boolean' }
464         ],
465         return => {
466             desc => 'list of holds, event on error',
467         }
468    }
469 );
470
471 __PACKAGE__->register_method(
472     method        => "retrieve_holds",
473     api_name      => "open-ils.circ.holds.id_list.retrieve",
474     authoritative => 1,
475     signature     => {
476         desc   => "Retrieves all the hold IDs, for the specified user.  $ses_is_req_note",
477         params => [
478             { desc => 'Authentication token', type => 'string'  },
479             { desc => 'User ID',              type => 'integer' },
480             { desc => 'Available Only',       type => 'boolean' }
481         ],
482         return => {
483             desc => 'list of holds, event on error',
484         }
485    }
486 );
487
488 __PACKAGE__->register_method(
489     method        => "retrieve_holds",
490     api_name      => "open-ils.circ.holds.canceled.retrieve",
491     authoritative => 1,
492     signature     => {
493         desc   => "Retrieves all the cancelled holds for the specified user.  $ses_is_req_note",
494         params => [
495             { desc => 'Authentication token', type => 'string'  },
496             { desc => 'User ID',              type => 'integer' }
497         ],
498         return => {
499             desc => 'list of holds, event on error',
500         }
501    }
502 );
503
504 __PACKAGE__->register_method(
505     method        => "retrieve_holds",
506     api_name      => "open-ils.circ.holds.canceled.id_list.retrieve",
507     authoritative => 1,
508     signature     => {
509         desc   => "Retrieves list of cancelled hold IDs for the specified user.  $ses_is_req_note",
510         params => [
511             { desc => 'Authentication token', type => 'string'  },
512             { desc => 'User ID',              type => 'integer' }
513         ],
514         return => {
515             desc => 'list of hold IDs, event on error',
516         }
517    }
518 );
519
520
521 sub retrieve_holds {
522     my ($self, $client, $auth, $user_id, $available) = @_;
523
524     my $e = new_editor(authtoken=>$auth);
525     return $e->event unless $e->checkauth;
526     $user_id = $e->requestor->id unless defined $user_id;
527
528     my $notes_filter = {staff => 'f'};
529     my $user = $e->retrieve_actor_user($user_id) or return $e->event;
530     unless($user_id == $e->requestor->id) {
531         if($e->allowed('VIEW_HOLD', $user->home_ou)) {
532             $notes_filter = {staff => 't'}
533         } else {
534             my $allowed = OpenILS::Application::Actor::Friends->friend_perm_allowed(
535                 $e, $user_id, $e->requestor->id, 'hold.view');
536             return $e->event unless $allowed;
537         }
538     } else {
539         # staff member looking at his/her own holds can see staff and non-staff notes
540         $notes_filter = {} if $e->allowed('VIEW_HOLD', $user->home_ou);
541     }
542
543     my $holds_query = {
544         select => {ahr => ['id']},
545         from => 'ahr',
546         where => {usr => $user_id, fulfillment_time => undef}
547     };
548
549     if($self->api_name =~ /canceled/) {
550
551         # Fetch the canceled holds
552         # order cancelled holds by cancel time, most recent first
553
554         $holds_query->{order_by} = [{class => 'ahr', field => 'cancel_time', direction => 'desc'}];
555
556         my $cancel_age;
557         my $cancel_count = $U->ou_ancestor_setting_value(
558                 $e->requestor->ws_ou, 'circ.holds.canceled.display_count', $e);
559
560         unless($cancel_count) {
561             $cancel_age = $U->ou_ancestor_setting_value(
562                 $e->requestor->ws_ou, 'circ.holds.canceled.display_age', $e);
563
564             # if no settings are defined, default to last 10 cancelled holds
565             $cancel_count = 10 unless $cancel_age;
566         }
567
568         if($cancel_count) { # limit by count
569
570             $holds_query->{where}->{cancel_time} = {'!=' => undef};
571             $holds_query->{limit} = $cancel_count;
572
573         } elsif($cancel_age) { # limit by age
574
575             # find all of the canceled holds that were canceled within the configured time frame
576             my $date = DateTime->now->subtract(seconds => OpenSRF::Utils::interval_to_seconds($cancel_age));
577             $date = $U->epoch2ISO8601($date->epoch);
578             $holds_query->{where}->{cancel_time} = {'>=' => $date};
579         }
580
581     } else {
582
583         # order non-cancelled holds by ready-for-pickup, then active, followed by suspended
584         # "compare" sorts false values to the front.  testing pickup_lib != current_shelf_lib
585         # will sort by pl = csl > pl != csl > followed by csl is null;
586         $holds_query->{order_by} = [
587             {   class => 'ahr',
588                 field => 'pickup_lib',
589                 compare => {'!='  => {'+ahr' => 'current_shelf_lib'}}},
590             {class => 'ahr', field => 'shelf_time'},
591             {class => 'ahr', field => 'frozen'},
592             {class => 'ahr', field => 'request_time'}
593
594         ];
595         $holds_query->{where}->{cancel_time} = undef;
596         if($available) {
597             $holds_query->{where}->{shelf_time} = {'!=' => undef};
598             # Maybe?
599             $holds_query->{where}->{pickup_lib} = {'=' => {'+ahr' => 'current_shelf_lib'}};
600         }
601     }
602
603     my $hold_ids = $e->json_query($holds_query);
604     $hold_ids = [ map { $_->{id} } @$hold_ids ];
605
606     return $hold_ids if $self->api_name =~ /id_list/;
607
608     my @holds;
609     for my $hold_id ( @$hold_ids ) {
610
611         my $hold = $e->retrieve_action_hold_request($hold_id);
612         $hold->notes($e->search_action_hold_request_note({hold => $hold_id, %$notes_filter}));
613
614         $hold->transit(
615             $e->search_action_hold_transit_copy([
616                 {hold => $hold->id},
617                 {order_by => {ahtc => 'source_send_time desc'}, limit => 1}])->[0]
618         );
619
620         push(@holds, $hold);
621     }
622
623     return \@holds;
624 }
625
626
627 __PACKAGE__->register_method(
628     method   => 'user_hold_count',
629     api_name => 'open-ils.circ.hold.user.count'
630 );
631
632 sub user_hold_count {
633     my ( $self, $conn, $auth, $userid ) = @_;
634     my $e = new_editor( authtoken => $auth );
635     return $e->event unless $e->checkauth;
636     my $patron = $e->retrieve_actor_user($userid)
637         or return $e->event;
638     return $e->event unless $e->allowed( 'VIEW_HOLD', $patron->home_ou );
639     return __user_hold_count( $self, $e, $userid );
640 }
641
642 sub __user_hold_count {
643     my ( $self, $e, $userid ) = @_;
644     my $holds = $e->search_action_hold_request(
645         {
646             usr              => $userid,
647             fulfillment_time => undef,
648             cancel_time      => undef,
649         },
650         { idlist => 1 }
651     );
652
653     return scalar(@$holds);
654 }
655
656
657 __PACKAGE__->register_method(
658     method   => "retrieve_holds_by_pickup_lib",
659     api_name => "open-ils.circ.holds.retrieve_by_pickup_lib",
660     notes    =>
661       "Retrieves all the holds, with hold transits attached, for the specified pickup_ou id."
662 );
663
664 __PACKAGE__->register_method(
665     method   => "retrieve_holds_by_pickup_lib",
666     api_name => "open-ils.circ.holds.id_list.retrieve_by_pickup_lib",
667     notes    => "Retrieves all the hold ids for the specified pickup_ou id. "
668 );
669
670 sub retrieve_holds_by_pickup_lib {
671     my ($self, $client, $login_session, $ou_id) = @_;
672
673     #FIXME -- put an appropriate permission check here
674     #my( $user, $target, $evt ) = $apputils->checkses_requestor(
675     #    $login_session, $user_id, 'VIEW_HOLD' );
676     #return $evt if $evt;
677
678     my $holds = $apputils->simplereq(
679         'open-ils.cstore',
680         "open-ils.cstore.direct.action.hold_request.search.atomic",
681         {
682             pickup_lib =>  $ou_id ,
683             fulfillment_time => undef,
684             cancel_time => undef
685         },
686         { order_by => { ahr => "request_time" } }
687     );
688
689     if ( ! $self->api_name =~ /id_list/ ) {
690         flesh_hold_transits($holds);
691         return $holds;
692     }
693     # else id_list
694     return [ map { $_->id } @$holds ];
695 }
696
697
698 __PACKAGE__->register_method(
699     method   => "uncancel_hold",
700     api_name => "open-ils.circ.hold.uncancel"
701 );
702
703 sub uncancel_hold {
704     my($self, $client, $auth, $hold_id) = @_;
705     my $e = new_editor(authtoken=>$auth, xact=>1);
706     return $e->die_event unless $e->checkauth;
707
708     my $hold = $e->retrieve_action_hold_request($hold_id)
709         or return $e->die_event;
710     return $e->die_event unless $e->allowed('CANCEL_HOLDS', $hold->request_lib);
711
712     if ($hold->fulfillment_time) {
713         $e->rollback;
714         return 0;
715     }
716     unless ($hold->cancel_time) {
717         $e->rollback;
718         return 1;
719     }
720
721     # if configured to reset the request time, also reset the expire time
722     if($U->ou_ancestor_setting_value(
723         $hold->request_lib, 'circ.holds.uncancel.reset_request_time', $e)) {
724
725         $hold->request_time('now');
726         $hold->expire_time(calculate_expire_time($hold->request_lib));
727     }
728
729     $hold->clear_cancel_time;
730     $hold->clear_cancel_cause;
731     $hold->clear_cancel_note;
732     $hold->clear_shelf_time;
733     $hold->clear_current_copy;
734     $hold->clear_capture_time;
735     $hold->clear_prev_check_time;
736     $hold->clear_shelf_expire_time;
737     $hold->clear_current_shelf_lib;
738
739     $e->update_action_hold_request($hold) or return $e->die_event;
740     $e->commit;
741
742     $U->storagereq('open-ils.storage.action.hold_request.copy_targeter', undef, $hold_id);
743
744     return 1;
745 }
746
747
748 __PACKAGE__->register_method(
749     method    => "cancel_hold",
750     api_name  => "open-ils.circ.hold.cancel",
751     signature => {
752         desc   => 'Cancels the specified hold.  The login session is the requestor.  If the requestor is different from the usr field ' .
753                   'on the hold, the requestor must have CANCEL_HOLDS permissions. The hold may be either the hold object or the hold id',
754         param  => [
755             {desc => 'Authentication token',  type => 'string'},
756             {desc => 'Hold ID',               type => 'number'},
757             {desc => 'Cause of Cancellation', type => 'string'},
758             {desc => 'Note',                  type => 'string'}
759         ],
760         return => {
761             desc => '1 on success, event on error'
762         }
763     }
764 );
765
766 sub cancel_hold {
767     my($self, $client, $auth, $holdid, $cause, $note) = @_;
768
769     my $e = new_editor(authtoken=>$auth, xact=>1);
770     return $e->die_event unless $e->checkauth;
771
772     my $hold = $e->retrieve_action_hold_request($holdid)
773         or return $e->die_event;
774
775     if( $e->requestor->id ne $hold->usr ) {
776         return $e->die_event unless $e->allowed('CANCEL_HOLDS');
777     }
778
779     if ($hold->cancel_time) {
780         $e->rollback;
781         return 1;
782     }
783
784     # If the hold is captured, reset the copy status
785     if( $hold->capture_time and $hold->current_copy ) {
786
787         my $copy = $e->retrieve_asset_copy($hold->current_copy)
788             or return $e->die_event;
789
790         if( $copy->status == OILS_COPY_STATUS_ON_HOLDS_SHELF ) {
791          $logger->info("canceling hold $holdid whose item is on the holds shelf");
792 #            $logger->info("setting copy to status 'reshelving' on hold cancel");
793 #            $copy->status(OILS_COPY_STATUS_RESHELVING);
794 #            $copy->editor($e->requestor->id);
795 #            $copy->edit_date('now');
796 #            $e->update_asset_copy($copy) or return $e->event;
797
798         } elsif( $copy->status == OILS_COPY_STATUS_IN_TRANSIT ) {
799
800             my $hid = $hold->id;
801             $logger->warn("! canceling hold [$hid] that is in transit");
802             my $transid = $e->search_action_hold_transit_copy({hold=>$hold->id},{idlist=>1})->[0];
803
804             if( $transid ) {
805                 my $trans = $e->retrieve_action_transit_copy($transid);
806                 # Leave the transit alive, but  set the copy status to
807                 # reshelving so it will be properly reshelved when it gets back home
808                 if( $trans ) {
809                     $trans->copy_status( OILS_COPY_STATUS_RESHELVING );
810                     $e->update_action_transit_copy($trans) or return $e->die_event;
811                 }
812             }
813         }
814     }
815
816     $hold->cancel_time('now');
817     $hold->cancel_cause($cause);
818     $hold->cancel_note($note);
819     $e->update_action_hold_request($hold)
820         or return $e->die_event;
821
822     delete_hold_copy_maps($self, $e, $hold->id);
823
824     $e->commit;
825
826     # re-fetch the hold to pick up the real cancel_time (not "now") for A/T
827     $e->xact_begin;
828     $hold = $e->retrieve_action_hold_request($hold->id) or return $e->die_event;
829     $e->rollback;
830
831     if ($e->requestor->id == $hold->usr) {
832         $U->create_events_for_hook('hold_request.cancel.patron', $hold, $hold->pickup_lib);
833     } else {
834         $U->create_events_for_hook('hold_request.cancel.staff', $hold, $hold->pickup_lib);
835     }
836
837     return 1;
838 }
839
840 sub delete_hold_copy_maps {
841     my $class  = shift;
842     my $editor = shift;
843     my $holdid = shift;
844
845     my $maps = $editor->search_action_hold_copy_map({hold=>$holdid});
846     for(@$maps) {
847         $editor->delete_action_hold_copy_map($_)
848             or return $editor->event;
849     }
850     return undef;
851 }
852
853
854 my $update_hold_desc = 'The login session is the requestor. '       .
855    'If the requestor is different from the usr field on the hold, ' .
856    'the requestor must have UPDATE_HOLDS permissions. '             .
857    'If supplying a hash of hold data, "id" must be included. '      .
858    'The hash is ignored if a hold object is supplied, '             .
859    'so you should supply only one kind of hold data argument.'      ;
860
861 __PACKAGE__->register_method(
862     method    => "update_hold",
863     api_name  => "open-ils.circ.hold.update",
864     signature => {
865         desc   => "Updates the specified hold.  $update_hold_desc",
866         params => [
867             {desc => 'Authentication token',         type => 'string'},
868             {desc => 'Hold Object',                  type => 'object'},
869             {desc => 'Hash of values to be applied', type => 'object'}
870         ],
871         return => {
872             desc => 'Hold ID on success, event on error',
873             # type => 'number'
874         }
875     }
876 );
877
878 __PACKAGE__->register_method(
879     method    => "batch_update_hold",
880     api_name  => "open-ils.circ.hold.update.batch",
881     stream    => 1,
882     signature => {
883         desc   => "Updates the specified hold(s).  $update_hold_desc",
884         params => [
885             {desc => 'Authentication token',                    type => 'string'},
886             {desc => 'Array of hold obejcts',                   type => 'array' },
887             {desc => 'Array of hashes of values to be applied', type => 'array' }
888         ],
889         return => {
890             desc => 'Hold ID per success, event per error',
891         }
892     }
893 );
894
895 sub update_hold {
896     my($self, $client, $auth, $hold, $values) = @_;
897     my $e = new_editor(authtoken=>$auth, xact=>1);
898     return $e->die_event unless $e->checkauth;
899     my $resp = update_hold_impl($self, $e, $hold, $values);
900     if ($U->event_code($resp)) {
901         $e->rollback;
902         return $resp;
903     }
904     $e->commit;     # FIXME: update_hold_impl already does $e->commit  ??
905     return $resp;
906 }
907
908 sub batch_update_hold {
909     my($self, $client, $auth, $hold_list, $values_list) = @_;
910     my $e = new_editor(authtoken=>$auth);
911     return $e->die_event unless $e->checkauth;
912
913     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.
914     $hold_list   ||= [];
915     $values_list ||= [];      # FIXME: either move this above $count declaration, or send an event if both lists undef.  Probably the latter.
916
917 # FIXME: Failing over to [] guarantees warnings for "Use of unitialized value" in update_hold_impl call.
918 # FIXME: We should be sure we only call update_hold_impl with hold object OR hash, not both.
919
920     for my $idx (0..$count-1) {
921         $e->xact_begin;
922         my $resp = update_hold_impl($self, $e, $hold_list->[$idx], $values_list->[$idx]);
923         $e->xact_commit unless $U->event_code($resp);
924         $client->respond($resp);
925     }
926
927     $e->disconnect;
928     return undef;       # not in the register return type, assuming we should always have at least one list populated
929 }
930
931 sub update_hold_impl {
932     my($self, $e, $hold, $values) = @_;
933     my $hold_status;
934     my $need_retarget = 0;
935
936     unless($hold) {
937         $hold = $e->retrieve_action_hold_request($values->{id})
938             or return $e->die_event;
939         for my $k (keys %$values) {
940             # Outside of pickup_lib (covered by the first regex) I don't know when these would currently change.
941             # But hey, why not cover things that may happen later?
942             if ($k =~ '_(lib|ou)$' || $k eq 'target' || $k eq 'hold_type' || $k eq 'requestor' || $k eq 'selection_depth' || $k eq 'holdable_formats') {
943                 if (defined $values->{$k} && defined $hold->$k() && $values->{$k} ne $hold->$k()) {
944                     # Value changed? RETARGET!
945                     $need_retarget = 1;
946                 } elsif (defined $hold->$k() != defined $values->{$k}) {
947                     # Value being set or cleared? RETARGET!
948                     $need_retarget = 1;
949                 }
950             }
951             if (defined $values->{$k}) {
952                 $hold->$k($values->{$k});
953             } else {
954                 my $f = "clear_$k"; $hold->$f();
955             }
956         }
957     }
958
959     my $orig_hold = $e->retrieve_action_hold_request($hold->id)
960         or return $e->die_event;
961
962     # don't allow the user to be changed
963     return OpenILS::Event->new('BAD_PARAMS') if $hold->usr != $orig_hold->usr;
964
965     if($hold->usr ne $e->requestor->id) {
966         # if the hold is for a different user, make sure the
967         # requestor has the appropriate permissions
968         my $usr = $e->retrieve_actor_user($hold->usr)
969             or return $e->die_event;
970         return $e->die_event unless $e->allowed('UPDATE_HOLD', $usr->home_ou);
971     }
972
973
974     # --------------------------------------------------------------
975     # Changing the request time is like playing God
976     # --------------------------------------------------------------
977     if($hold->request_time ne $orig_hold->request_time) {
978         return OpenILS::Event->new('BAD_PARAMS') if $hold->fulfillment_time;
979         return $e->die_event unless $e->allowed('UPDATE_HOLD_REQUEST_TIME', $hold->pickup_lib);
980     }
981
982
983     # --------------------------------------------------------------
984     # Code for making sure staff have appropriate permissons for cut_in_line
985     # This, as is, doesn't prevent a user from cutting their own holds in line
986     # but needs to
987     # --------------------------------------------------------------
988     if($U->is_true($hold->cut_in_line) ne $U->is_true($orig_hold->cut_in_line)) {
989         return $e->die_event unless $e->allowed('UPDATE_HOLD_REQUEST_TIME', $hold->pickup_lib);
990     }
991
992
993     # --------------------------------------------------------------
994     # Disallow hold suspencion if the hold is already captured.
995     # --------------------------------------------------------------
996     if ($U->is_true($hold->frozen) and not $U->is_true($orig_hold->frozen)) {
997         $hold_status = _hold_status($e, $hold);
998         if ($hold_status > 2 && $hold_status != 7) { # hold is captured
999             $logger->info("bypassing hold freeze on captured hold");
1000             return OpenILS::Event->new('HOLD_SUSPEND_AFTER_CAPTURE');
1001         }
1002     }
1003
1004
1005     # --------------------------------------------------------------
1006     # if the hold is on the holds shelf or in transit and the pickup
1007     # lib changes we need to create a new transit.
1008     # --------------------------------------------------------------
1009     if($orig_hold->pickup_lib ne $hold->pickup_lib) {
1010
1011         $hold_status = _hold_status($e, $hold) unless $hold_status;
1012
1013         if($hold_status == 3) { # in transit
1014
1015             return $e->die_event unless $e->allowed('UPDATE_PICKUP_LIB_FROM_TRANSIT', $orig_hold->pickup_lib);
1016             return $e->die_event unless $e->allowed('UPDATE_PICKUP_LIB_FROM_TRANSIT', $hold->pickup_lib);
1017
1018             $logger->info("updating pickup lib for hold ".$hold->id." while already in transit");
1019
1020             # update the transit to reflect the new pickup location
1021             my $transit = $e->search_action_hold_transit_copy(
1022                 {hold=>$hold->id, dest_recv_time => undef})->[0]
1023                 or return $e->die_event;
1024
1025             $transit->prev_dest($transit->dest); # mark the previous destination on the transit
1026             $transit->dest($hold->pickup_lib);
1027             $e->update_action_hold_transit_copy($transit) or return $e->die_event;
1028
1029         } elsif($hold_status == 4 or $hold_status == 5 or $hold_status == 8) { # on holds shelf
1030
1031             return $e->die_event unless $e->allowed('UPDATE_PICKUP_LIB_FROM_HOLDS_SHELF', $orig_hold->pickup_lib);
1032             return $e->die_event unless $e->allowed('UPDATE_PICKUP_LIB_FROM_HOLDS_SHELF', $hold->pickup_lib);
1033
1034             $logger->info("updating pickup lib for hold ".$hold->id." while on holds shelf");
1035
1036             if ($hold->pickup_lib eq $orig_hold->current_shelf_lib) {
1037                 # This can happen if the pickup lib is changed while the hold is
1038                 # on the shelf, then changed back to the original pickup lib.
1039                 # Restore the original shelf_expire_time to prevent abuse.
1040                 set_hold_shelf_expire_time(undef, $hold, $e, $hold->shelf_time);
1041
1042             } else {
1043                 # clear to prevent premature shelf expiration
1044                 $hold->clear_shelf_expire_time;
1045             }
1046         }
1047     }
1048
1049     if($U->is_true($hold->frozen)) {
1050         $logger->info("clearing current_copy and check_time for frozen hold ".$hold->id);
1051         $hold->clear_current_copy;
1052         $hold->clear_prev_check_time;
1053         # Clear expire_time to prevent frozen holds from expiring.
1054         $logger->info("clearing expire_time for frozen hold ".$hold->id);
1055         $hold->clear_expire_time;
1056     }
1057
1058     # If the hold_expire_time is in the past && is not equal to the
1059     # original expire_time, then reset the expire time to be in the
1060     # future.
1061     if ($hold->expire_time && $U->datecmp($hold->expire_time) == -1 && $U->datecmp($hold->expire_time, $orig_hold->expire_time) != 0) {
1062         $hold->expire_time(calculate_expire_time($hold->request_lib));
1063     }
1064
1065     # If the hold is reactivated, reset the expire_time.
1066     if(!$U->is_true($hold->frozen) && $U->is_true($orig_hold->frozen)) {
1067         $logger->info("Reset expire_time on activated hold ".$hold->id);
1068         $hold->expire_time(calculate_expire_time($hold->request_lib));
1069     }
1070
1071     $e->update_action_hold_request($hold) or return $e->die_event;
1072     $e->commit;
1073
1074     if(!$U->is_true($hold->frozen) && $U->is_true($orig_hold->frozen)) {
1075         $logger->info("Running targeter on activated hold ".$hold->id);
1076         $U->storagereq( 'open-ils.storage.action.hold_request.copy_targeter', undef, $hold->id );
1077     }
1078
1079     # a change to mint-condition changes the set of potential copies, so retarget the hold;
1080     if($U->is_true($hold->mint_condition) and !$U->is_true($orig_hold->mint_condition)) {
1081         _reset_hold($self, $e->requestor, $hold)
1082     } elsif($need_retarget && !defined $hold->capture_time()) { # If needed, retarget the hold due to changes
1083         $U->storagereq(
1084             'open-ils.storage.action.hold_request.copy_targeter', undef, $hold->id );
1085     }
1086
1087     return $hold->id;
1088 }
1089
1090 # this does not update the hold in the DB.  It only
1091 # sets the shelf_expire_time field on the hold object.
1092 # start_time is optional and defaults to 'now'
1093 sub set_hold_shelf_expire_time {
1094     my ($class, $hold, $editor, $start_time) = @_;
1095
1096     my $shelf_expire = $U->ou_ancestor_setting_value(
1097         $hold->pickup_lib,
1098         'circ.holds.default_shelf_expire_interval',
1099         $editor
1100     );
1101
1102     return undef unless $shelf_expire;
1103
1104     $start_time = ($start_time) ?
1105         DateTime::Format::ISO8601->new->parse_datetime(cleanse_ISO8601($start_time)) :
1106         DateTime->now;
1107
1108     my $seconds = OpenSRF::Utils->interval_to_seconds($shelf_expire);
1109     my $expire_time = $start_time->add(seconds => $seconds);
1110
1111     # if the shelf expire time overlaps with a pickup lib's
1112     # closed date, push it out to the first open date
1113     my $dateinfo = $U->storagereq(
1114         'open-ils.storage.actor.org_unit.closed_date.overlap',
1115         $hold->pickup_lib, $expire_time->strftime('%FT%T%z'));
1116
1117     if($dateinfo) {
1118         my $dt_parser = DateTime::Format::ISO8601->new;
1119         $expire_time = $dt_parser->parse_datetime(cleanse_ISO8601($dateinfo->{end}));
1120
1121         # TODO: enable/disable time bump via setting?
1122         $expire_time->set(hour => '23', minute => '59', second => '59');
1123
1124         $logger->info("circulator: shelf_expire_time overlaps".
1125             " with closed date, pushing expire time to $expire_time");
1126     }
1127
1128     $hold->shelf_expire_time($expire_time->strftime('%FT%T%z'));
1129     return undef;
1130 }
1131
1132
1133 sub transit_hold {
1134     my($e, $orig_hold, $hold, $copy) = @_;
1135     my $src  = $orig_hold->pickup_lib;
1136     my $dest = $hold->pickup_lib;
1137
1138     $logger->info("putting hold into transit on pickup_lib update");
1139
1140     my $transit = Fieldmapper::action::hold_transit_copy->new;
1141     $transit->hold($hold->id);
1142     $transit->source($src);
1143     $transit->dest($dest);
1144     $transit->target_copy($copy->id);
1145     $transit->source_send_time('now');
1146     $transit->copy_status(OILS_COPY_STATUS_ON_HOLDS_SHELF);
1147
1148     $copy->status(OILS_COPY_STATUS_IN_TRANSIT);
1149     $copy->editor($e->requestor->id);
1150     $copy->edit_date('now');
1151
1152     $e->create_action_hold_transit_copy($transit) or return $e->die_event;
1153     $e->update_asset_copy($copy) or return $e->die_event;
1154     return undef;
1155 }
1156
1157 # if the hold is frozen, this method ensures that the hold is not "targeted",
1158 # that is, it clears the current_copy and prev_check_time to essentiallly
1159 # reset the hold.  If it is being activated, it runs the targeter in the background
1160 sub update_hold_if_frozen {
1161     my($self, $e, $hold, $orig_hold) = @_;
1162     return if $hold->capture_time;
1163
1164     if($U->is_true($hold->frozen)) {
1165         $logger->info("clearing current_copy and check_time for frozen hold ".$hold->id);
1166         $hold->clear_current_copy;
1167         $hold->clear_prev_check_time;
1168
1169     } else {
1170         if($U->is_true($orig_hold->frozen)) {
1171             $logger->info("Running targeter on activated hold ".$hold->id);
1172             $U->storagereq( 'open-ils.storage.action.hold_request.copy_targeter', undef, $hold->id );
1173         }
1174     }
1175 }
1176
1177 __PACKAGE__->register_method(
1178     method    => "hold_note_CUD",
1179     api_name  => "open-ils.circ.hold_request.note.cud",
1180     signature => {
1181         desc   => 'Create, update or delete a hold request note.  If the operator (from Auth. token) '
1182                 . 'is not the owner of the hold, the UPDATE_HOLD permission is required',
1183         params => [
1184             { desc => 'Authentication token', type => 'string' },
1185             { desc => 'Hold note object',     type => 'object' }
1186         ],
1187         return => {
1188             desc => 'Returns the note ID, event on error'
1189         },
1190     }
1191 );
1192
1193 sub hold_note_CUD {
1194     my($self, $conn, $auth, $note) = @_;
1195
1196     my $e = new_editor(authtoken => $auth, xact => 1);
1197     return $e->die_event unless $e->checkauth;
1198
1199     my $hold = $e->retrieve_action_hold_request($note->hold)
1200         or return $e->die_event;
1201
1202     if($hold->usr ne $e->requestor->id) {
1203         my $usr = $e->retrieve_actor_user($hold->usr);
1204         return $e->die_event unless $e->allowed('UPDATE_HOLD', $usr->home_ou);
1205         $note->staff('t') if $note->isnew;
1206     }
1207
1208     if($note->isnew) {
1209         $e->create_action_hold_request_note($note) or return $e->die_event;
1210     } elsif($note->ischanged) {
1211         $e->update_action_hold_request_note($note) or return $e->die_event;
1212     } elsif($note->isdeleted) {
1213         $e->delete_action_hold_request_note($note) or return $e->die_event;
1214     }
1215
1216     $e->commit;
1217     return $note->id;
1218 }
1219
1220
1221 __PACKAGE__->register_method(
1222     method    => "retrieve_hold_status",
1223     api_name  => "open-ils.circ.hold.status.retrieve",
1224     signature => {
1225         desc   => 'Calculates the current status of the hold. The requestor must have '      .
1226                   'VIEW_HOLD permissions if the hold is for a user other than the requestor' ,
1227         param  => [
1228             { desc => 'Hold ID', type => 'number' }
1229         ],
1230         return => {
1231             # type => 'number',     # event sometimes
1232             desc => <<'END_OF_DESC'
1233 Returns event on error or:
1234 -1 on error (for now),
1235  1 for 'waiting for copy to become available',
1236  2 for 'waiting for copy capture',
1237  3 for 'in transit',
1238  4 for 'arrived',
1239  5 for 'hold-shelf-delay'
1240  6 for 'canceled'
1241  7 for 'suspended'
1242  8 for 'captured, on wrong hold shelf'
1243 END_OF_DESC
1244         }
1245     }
1246 );
1247
1248 sub retrieve_hold_status {
1249     my($self, $client, $auth, $hold_id) = @_;
1250
1251     my $e = new_editor(authtoken => $auth);
1252     return $e->event unless $e->checkauth;
1253     my $hold = $e->retrieve_action_hold_request($hold_id)
1254         or return $e->event;
1255
1256     if( $e->requestor->id != $hold->usr ) {
1257         return $e->event unless $e->allowed('VIEW_HOLD');
1258     }
1259
1260     return _hold_status($e, $hold);
1261
1262 }
1263
1264 sub _hold_status {
1265     my($e, $hold) = @_;
1266     if ($hold->cancel_time) {
1267         return 6;
1268     }
1269     if ($U->is_true($hold->frozen) && !$hold->capture_time) {
1270         return 7;
1271     }
1272     if ($hold->current_shelf_lib and $hold->current_shelf_lib ne $hold->pickup_lib) {
1273         return 8;
1274     }
1275     return 1 unless $hold->current_copy;
1276     return 2 unless $hold->capture_time;
1277
1278     my $copy = $hold->current_copy;
1279     unless( ref $copy ) {
1280         $copy = $e->retrieve_asset_copy($hold->current_copy)
1281             or return $e->event;
1282     }
1283
1284     return 3 if $copy->status == OILS_COPY_STATUS_IN_TRANSIT;
1285
1286     if($copy->status == OILS_COPY_STATUS_ON_HOLDS_SHELF) {
1287
1288         my $hs_wait_interval = $U->ou_ancestor_setting_value($hold->pickup_lib, 'circ.hold_shelf_status_delay');
1289         return 4 unless $hs_wait_interval;
1290
1291         # if a hold_shelf_status_delay interval is defined and start_time plus
1292         # the interval is greater than now, consider the hold to be in the virtual
1293         # "on its way to the holds shelf" status. Return 5.
1294
1295         my $transit    = $e->search_action_hold_transit_copy({hold => $hold->id})->[0];
1296         my $start_time = ($transit) ? $transit->dest_recv_time : $hold->capture_time;
1297         $start_time    = DateTime::Format::ISO8601->new->parse_datetime(cleanse_ISO8601($start_time));
1298         my $end_time   = $start_time->add(seconds => OpenSRF::Utils::interval_to_seconds($hs_wait_interval));
1299
1300         return 5 if $end_time > DateTime->now;
1301         return 4;
1302     }
1303
1304     return -1;  # error
1305 }
1306
1307
1308
1309 __PACKAGE__->register_method(
1310     method    => "retrieve_hold_queue_stats",
1311     api_name  => "open-ils.circ.hold.queue_stats.retrieve",
1312     signature => {
1313         desc   => 'Returns summary data about the state of a hold',
1314         params => [
1315             { desc => 'Authentication token',  type => 'string'},
1316             { desc => 'Hold ID', type => 'number'},
1317         ],
1318         return => {
1319             desc => q/Summary object with keys:
1320                 total_holds : total holds in queue
1321                 queue_position : current queue position
1322                 potential_copies : number of potential copies for this hold
1323                 estimated_wait : estimated wait time in days
1324                 status : hold status
1325                      -1 => error or unexpected state,
1326                      1 => 'waiting for copy to become available',
1327                      2 => 'waiting for copy capture',
1328                      3 => 'in transit',
1329                      4 => 'arrived',
1330                      5 => 'hold-shelf-delay'
1331             /,
1332             type => 'object'
1333         }
1334     }
1335 );
1336
1337 sub retrieve_hold_queue_stats {
1338     my($self, $conn, $auth, $hold_id) = @_;
1339     my $e = new_editor(authtoken => $auth);
1340     return $e->event unless $e->checkauth;
1341     my $hold = $e->retrieve_action_hold_request($hold_id) or return $e->event;
1342     if($e->requestor->id != $hold->usr) {
1343         return $e->event unless $e->allowed('VIEW_HOLD');
1344     }
1345     return retrieve_hold_queue_status_impl($e, $hold);
1346 }
1347
1348 sub retrieve_hold_queue_status_impl {
1349     my $e = shift;
1350     my $hold = shift;
1351
1352     # The holds queue is defined as the distinct set of holds that share at
1353     # least one potential copy with the context hold, plus any holds that
1354     # share the same hold type and target.  The latter part exists to
1355     # accomodate holds that currently have no potential copies
1356     my $q_holds = $e->json_query({
1357
1358         # fetch cut_in_line and request_time since they're in the order_by
1359         # and we're asking for distinct values
1360         select => {ahr => ['id', 'cut_in_line', 'request_time']},
1361         from   => 'ahr',
1362         where => {
1363             id => { in => {
1364                 select => { ahcm => ['hold'] },
1365                 from   => {
1366                     'ahcm' => {
1367                         'ahcm2' => {
1368                             'class' => 'ahcm',
1369                             'field' => 'target_copy',
1370                             'fkey'  => 'target_copy'
1371                         }
1372                     }
1373                 },
1374                 where => { '+ahcm2' => { hold => $hold->id } },
1375                 distinct => 1
1376             }}
1377         },
1378         order_by => [
1379             {
1380                 "class" => "ahr",
1381                 "field" => "cut_in_line",
1382                 "transform" => "coalesce",
1383                 "params" => [ 0 ],
1384                 "direction" => "desc"
1385             },
1386             { "class" => "ahr", "field" => "request_time" }
1387         ],
1388         distinct => 1
1389     });
1390
1391     if (!@$q_holds) { # none? maybe we don't have a map ...
1392         $q_holds = $e->json_query({
1393             select => {ahr => ['id', 'cut_in_line', 'request_time']},
1394             from   => 'ahr',
1395             order_by => [
1396                 {
1397                     "class" => "ahr",
1398                     "field" => "cut_in_line",
1399                     "transform" => "coalesce",
1400                     "params" => [ 0 ],
1401                     "direction" => "desc"
1402                 },
1403                 { "class" => "ahr", "field" => "request_time" }
1404             ],
1405             where    => {
1406                 hold_type => $hold->hold_type,
1407                 target    => $hold->target,
1408                 capture_time => undef,
1409                 cancel_time => undef,
1410                 '-or' => [
1411                     {expire_time => undef },
1412                     {expire_time => {'>' => 'now'}}
1413                 ]
1414            }
1415         });
1416     }
1417
1418
1419     my $qpos = 1;
1420     for my $h (@$q_holds) {
1421         last if $h->{id} == $hold->id;
1422         $qpos++;
1423     }
1424
1425     my $hold_data = $e->json_query({
1426         select => {
1427             acp => [ {column => 'id', transform => 'count', aggregate => 1, alias => 'count'} ],
1428             ccm => [ {column =>'avg_wait_time'} ]
1429         },
1430         from => {
1431             ahcm => {
1432                 acp => {
1433                     join => {
1434                         ccm => {type => 'left'}
1435                     }
1436                 }
1437             }
1438         },
1439         where => {'+ahcm' => {hold => $hold->id} }
1440     });
1441
1442     my $user_org = $e->json_query({select => {au => ['home_ou']}, from => 'au', where => {id => $hold->usr}})->[0]->{home_ou};
1443
1444     my $default_wait = $U->ou_ancestor_setting_value($user_org, OILS_SETTING_HOLD_ESIMATE_WAIT_INTERVAL);
1445     my $min_wait = $U->ou_ancestor_setting_value($user_org, 'circ.holds.min_estimated_wait_interval');
1446     $min_wait = OpenSRF::Utils::interval_to_seconds($min_wait || '0 seconds');
1447     $default_wait ||= '0 seconds';
1448
1449     # Estimated wait time is the average wait time across the set
1450     # of potential copies, divided by the number of potential copies
1451     # times the queue position.
1452
1453     my $combined_secs = 0;
1454     my $num_potentials = 0;
1455
1456     for my $wait_data (@$hold_data) {
1457         my $count += $wait_data->{count};
1458         $combined_secs += $count *
1459             OpenSRF::Utils::interval_to_seconds($wait_data->{avg_wait_time} || $default_wait);
1460         $num_potentials += $count;
1461     }
1462
1463     my $estimated_wait = -1;
1464
1465     if($num_potentials) {
1466         my $avg_wait = $combined_secs / $num_potentials;
1467         $estimated_wait = $qpos * ($avg_wait / $num_potentials);
1468         $estimated_wait = $min_wait if $estimated_wait < $min_wait and $estimated_wait != -1;
1469     }
1470
1471     return {
1472         total_holds      => scalar(@$q_holds),
1473         queue_position   => $qpos,
1474         potential_copies => $num_potentials,
1475         status           => _hold_status( $e, $hold ),
1476         estimated_wait   => int($estimated_wait)
1477     };
1478 }
1479
1480
1481 sub fetch_open_hold_by_current_copy {
1482     my $class = shift;
1483     my $copyid = shift;
1484     my $hold = $apputils->simplereq(
1485         'open-ils.cstore',
1486         'open-ils.cstore.direct.action.hold_request.search.atomic',
1487         { current_copy =>  $copyid , cancel_time => undef, fulfillment_time => undef });
1488     return $hold->[0] if ref($hold);
1489     return undef;
1490 }
1491
1492 sub fetch_related_holds {
1493     my $class = shift;
1494     my $copyid = shift;
1495     return $apputils->simplereq(
1496         'open-ils.cstore',
1497         'open-ils.cstore.direct.action.hold_request.search.atomic',
1498         { current_copy =>  $copyid , cancel_time => undef, fulfillment_time => undef });
1499 }
1500
1501
1502 __PACKAGE__->register_method(
1503     method    => "hold_pull_list",
1504     api_name  => "open-ils.circ.hold_pull_list.retrieve",
1505     signature => {
1506         desc   => 'Returns (reference to) a list of holds that need to be "pulled" by a given location. ' .
1507                   'The location is determined by the login session.',
1508         params => [
1509             { desc => 'Limit (optional)',  type => 'number'},
1510             { desc => 'Offset (optional)', type => 'number'},
1511         ],
1512         return => {
1513             desc => 'reference to a list of holds, or event on failure',
1514         }
1515     }
1516 );
1517
1518 __PACKAGE__->register_method(
1519     method    => "hold_pull_list",
1520     api_name  => "open-ils.circ.hold_pull_list.id_list.retrieve",
1521     signature => {
1522         desc   => 'Returns (reference to) a list of holds IDs that need to be "pulled" by a given location. ' .
1523                   'The location is determined by the login session.',
1524         params => [
1525             { desc => 'Limit (optional)',  type => 'number'},
1526             { desc => 'Offset (optional)', type => 'number'},
1527         ],
1528         return => {
1529             desc => 'reference to a list of holds, or event on failure',
1530         }
1531     }
1532 );
1533
1534 __PACKAGE__->register_method(
1535     method    => "hold_pull_list",
1536     api_name  => "open-ils.circ.hold_pull_list.retrieve.count",
1537     signature => {
1538         desc   => 'Returns a count of holds that need to be "pulled" by a given location. ' .
1539                   'The location is determined by the login session.',
1540         params => [
1541             { desc => 'Limit (optional)',  type => 'number'},
1542             { desc => 'Offset (optional)', type => 'number'},
1543         ],
1544         return => {
1545             desc => 'Holds count (integer), or event on failure',
1546             # type => 'number'
1547         }
1548     }
1549 );
1550
1551
1552 sub hold_pull_list {
1553     my( $self, $conn, $authtoken, $limit, $offset ) = @_;
1554     my( $reqr, $evt ) = $U->checkses($authtoken);
1555     return $evt if $evt;
1556
1557     my $org = $reqr->ws_ou || $reqr->home_ou;
1558     # the perm locaiton shouldn't really matter here since holds
1559     # will exist all over and VIEW_HOLDS should be universal
1560     $evt = $U->check_perms($reqr->id, $org, 'VIEW_HOLD');
1561     return $evt if $evt;
1562
1563     if($self->api_name =~ /count/) {
1564
1565         my $count = $U->storagereq(
1566             'open-ils.storage.direct.action.hold_request.pull_list.current_copy_circ_lib.status_filtered.count',
1567             $org, $limit, $offset );
1568
1569         $logger->info("Grabbing pull list for org unit $org with $count items");
1570         return $count;
1571
1572     } elsif( $self->api_name =~ /id_list/ ) {
1573         return $U->storagereq(
1574             'open-ils.storage.direct.action.hold_request.pull_list.id_list.current_copy_circ_lib.status_filtered.atomic',
1575             $org, $limit, $offset );
1576
1577     } else {
1578         return $U->storagereq(
1579             'open-ils.storage.direct.action.hold_request.pull_list.search.current_copy_circ_lib.status_filtered.atomic',
1580             $org, $limit, $offset );
1581     }
1582 }
1583
1584 __PACKAGE__->register_method(
1585     method    => "print_hold_pull_list",
1586     api_name  => "open-ils.circ.hold_pull_list.print",
1587     signature => {
1588         desc   => 'Returns an HTML-formatted holds pull list',
1589         params => [
1590             { desc => 'Authtoken', type => 'string'},
1591             { desc => 'Org unit ID.  Optional, defaults to workstation org unit', type => 'number'},
1592         ],
1593         return => {
1594             desc => 'HTML string',
1595             type => 'string'
1596         }
1597     }
1598 );
1599
1600 sub print_hold_pull_list {
1601     my($self, $client, $auth, $org_id) = @_;
1602
1603     my $e = new_editor(authtoken=>$auth);
1604     return $e->event unless $e->checkauth;
1605
1606     $org_id = (defined $org_id) ? $org_id : $e->requestor->ws_ou;
1607     return $e->event unless $e->allowed('VIEW_HOLD', $org_id);
1608
1609     my $hold_ids = $U->storagereq(
1610         'open-ils.storage.direct.action.hold_request.pull_list.id_list.current_copy_circ_lib.status_filtered.atomic',
1611         $org_id, 10000);
1612
1613     return undef unless @$hold_ids;
1614
1615     $client->status(new OpenSRF::DomainObject::oilsContinueStatus);
1616
1617     # Holds will /NOT/ be in order after this ...
1618     my $holds = $e->search_action_hold_request({id => $hold_ids}, {substream => 1});
1619     $client->status(new OpenSRF::DomainObject::oilsContinueStatus);
1620
1621     # ... so we must resort.
1622     my $hold_map = +{map { $_->id => $_ } @$holds};
1623     my $sorted_holds = [];
1624     push @$sorted_holds, $hold_map->{$_} foreach @$hold_ids;
1625
1626     return $U->fire_object_event(
1627         undef, "ahr.format.pull_list", $sorted_holds,
1628         $org_id, undef, undef, $client
1629     );
1630
1631 }
1632
1633 __PACKAGE__->register_method(
1634     method    => "print_hold_pull_list_stream",
1635     stream   => 1,
1636     api_name  => "open-ils.circ.hold_pull_list.print.stream",
1637     signature => {
1638         desc   => 'Returns a stream of fleshed holds',
1639         params => [
1640             { desc => 'Authtoken', type => 'string'},
1641             { 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)',
1642               type => 'object'
1643             },
1644         ],
1645         return => {
1646             desc => 'A stream of fleshed holds',
1647             type => 'object'
1648         }
1649     }
1650 );
1651
1652 sub print_hold_pull_list_stream {
1653     my($self, $client, $auth, $params) = @_;
1654
1655     my $e = new_editor(authtoken=>$auth);
1656     return $e->die_event unless $e->checkauth;
1657
1658     delete($$params{org_id}) unless (int($$params{org_id}));
1659     delete($$params{limit}) unless (int($$params{limit}));
1660     delete($$params{offset}) unless (int($$params{offset}));
1661     delete($$params{chunk_size}) unless (int($$params{chunk_size}));
1662     delete($$params{chunk_size}) if  ($$params{chunk_size} && $$params{chunk_size} > 50); # keep the size reasonable
1663     $$params{chunk_size} ||= 10;
1664
1665     $$params{org_id} = (defined $$params{org_id}) ? $$params{org_id}: $e->requestor->ws_ou;
1666     return $e->die_event unless $e->allowed('VIEW_HOLD', $$params{org_id });
1667
1668     my $sort = [];
1669     if ($$params{sort} && @{ $$params{sort} }) {
1670         for my $s (@{ $$params{sort} }) {
1671             if ($s eq 'acplo.position') {
1672                 push @$sort, {
1673                     "class" => "acplo", "field" => "position",
1674                     "transform" => "coalesce", "params" => [999]
1675                 };
1676             } elsif ($s eq 'prefix') {
1677                 push @$sort, {"class" => "acnp", "field" => "label_sortkey"};
1678             } elsif ($s eq 'call_number') {
1679                 push @$sort, {"class" => "acn", "field" => "label_sortkey"};
1680             } elsif ($s eq 'suffix') {
1681                 push @$sort, {"class" => "acns", "field" => "label_sortkey"};
1682             } elsif ($s eq 'request_time') {
1683                 push @$sort, {"class" => "ahr", "field" => "request_time"};
1684             }
1685         }
1686     } else {
1687         push @$sort, {"class" => "ahr", "field" => "request_time"};
1688     }
1689
1690     my $holds_ids = $e->json_query(
1691         {
1692             "select" => {"ahr" => ["id"]},
1693             "from" => {
1694                 "ahr" => {
1695                     "acp" => {
1696                         "field" => "id",
1697                         "fkey" => "current_copy",
1698                         "filter" => {
1699                             "circ_lib" => $$params{org_id}, "status" => [0,7]
1700                         },
1701                         "join" => {
1702                             "acn" => {
1703                                 "field" => "id",
1704                                 "fkey" => "call_number",
1705                                 "join" => {
1706                                     "acnp" => {
1707                                         "field" => "id",
1708                                         "fkey" => "prefix"
1709                                     },
1710                                     "acns" => {
1711                                         "field" => "id",
1712                                         "fkey" => "suffix"
1713                                     }
1714                                 }
1715                             },
1716                             "acplo" => {
1717                                 "field" => "org",
1718                                 "fkey" => "circ_lib",
1719                                 "type" => "left",
1720                                 "filter" => {
1721                                     "location" => {"=" => {"+acp" => "location"}}
1722                                 }
1723                             }
1724                         }
1725                     }
1726                 }
1727             },
1728             "where" => {
1729                 "+ahr" => {
1730                     "capture_time" => undef,
1731                     "cancel_time" => undef,
1732                     "-or" => [
1733                         {"expire_time" => undef },
1734                         {"expire_time" => {">" => "now"}}
1735                     ]
1736                 }
1737             },
1738             (@$sort ? (order_by => $sort) : ()),
1739             ($$params{limit} ? (limit => $$params{limit}) : ()),
1740             ($$params{offset} ? (offset => $$params{offset}) : ())
1741         }, {"substream" => 1}
1742     ) or return $e->die_event;
1743
1744     $logger->info("about to stream back " . scalar(@$holds_ids) . " holds");
1745
1746     my @chunk;
1747     for my $hid (@$holds_ids) {
1748         push @chunk, $e->retrieve_action_hold_request([
1749             $hid->{"id"}, {
1750                 "flesh" => 3,
1751                 "flesh_fields" => {
1752                     "ahr" => ["usr", "current_copy"],
1753                     "au"  => ["card"],
1754                     "acp" => ["location", "call_number", "parts"],
1755                     "acn" => ["record","prefix","suffix"]
1756                 }
1757             }
1758         ]);
1759
1760         if (@chunk >= $$params{chunk_size}) {
1761             $client->respond( \@chunk );
1762             @chunk = ();
1763         }
1764     }
1765     $client->respond_complete( \@chunk ) if (@chunk);
1766     $e->disconnect;
1767     return undef;
1768 }
1769
1770
1771
1772 __PACKAGE__->register_method(
1773     method        => 'fetch_hold_notify',
1774     api_name      => 'open-ils.circ.hold_notification.retrieve_by_hold',
1775     authoritative => 1,
1776     signature     => q/
1777 Returns a list of hold notification objects based on hold id.
1778 @param authtoken The loggin session key
1779 @param holdid The id of the hold whose notifications we want to retrieve
1780 @return An array of hold notification objects, event on error.
1781 /
1782 );
1783
1784 sub fetch_hold_notify {
1785     my( $self, $conn, $authtoken, $holdid ) = @_;
1786     my( $requestor, $evt ) = $U->checkses($authtoken);
1787     return $evt if $evt;
1788     my ($hold, $patron);
1789     ($hold, $evt) = $U->fetch_hold($holdid);
1790     return $evt if $evt;
1791     ($patron, $evt) = $U->fetch_user($hold->usr);
1792     return $evt if $evt;
1793
1794     $evt = $U->check_perms($requestor->id, $patron->home_ou, 'VIEW_HOLD_NOTIFICATION');
1795     return $evt if $evt;
1796
1797     $logger->info("User ".$requestor->id." fetching hold notifications for hold $holdid");
1798     return $U->cstorereq(
1799         'open-ils.cstore.direct.action.hold_notification.search.atomic', {hold => $holdid} );
1800 }
1801
1802
1803 __PACKAGE__->register_method(
1804     method    => 'create_hold_notify',
1805     api_name  => 'open-ils.circ.hold_notification.create',
1806     signature => q/
1807 Creates a new hold notification object
1808 @param authtoken The login session key
1809 @param notification The hold notification object to create
1810 @return ID of the new object on success, Event on error
1811 /
1812 );
1813
1814 sub create_hold_notify {
1815    my( $self, $conn, $auth, $note ) = @_;
1816    my $e = new_editor(authtoken=>$auth, xact=>1);
1817    return $e->die_event unless $e->checkauth;
1818
1819    my $hold = $e->retrieve_action_hold_request($note->hold)
1820       or return $e->die_event;
1821    my $patron = $e->retrieve_actor_user($hold->usr)
1822       or return $e->die_event;
1823
1824    return $e->die_event unless
1825       $e->allowed('CREATE_HOLD_NOTIFICATION', $patron->home_ou);
1826
1827    $note->notify_staff($e->requestor->id);
1828    $e->create_action_hold_notification($note) or return $e->die_event;
1829    $e->commit;
1830    return $note->id;
1831 }
1832
1833 __PACKAGE__->register_method(
1834     method    => 'create_hold_note',
1835     api_name  => 'open-ils.circ.hold_note.create',
1836     signature => q/
1837         Creates a new hold request note object
1838         @param authtoken The login session key
1839         @param note The hold note object to create
1840         @return ID of the new object on success, Event on error
1841         /
1842 );
1843
1844 sub create_hold_note {
1845    my( $self, $conn, $auth, $note ) = @_;
1846    my $e = new_editor(authtoken=>$auth, xact=>1);
1847    return $e->die_event unless $e->checkauth;
1848
1849    my $hold = $e->retrieve_action_hold_request($note->hold)
1850       or return $e->die_event;
1851    my $patron = $e->retrieve_actor_user($hold->usr)
1852       or return $e->die_event;
1853
1854    return $e->die_event unless
1855       $e->allowed('UPDATE_HOLD', $patron->home_ou); # FIXME: Using permcrud perm listed in fm_IDL.xml for ahrn.  Probably want something more specific
1856
1857    $e->create_action_hold_request_note($note) or return $e->die_event;
1858    $e->commit;
1859    return $note->id;
1860 }
1861
1862 __PACKAGE__->register_method(
1863     method    => 'reset_hold',
1864     api_name  => 'open-ils.circ.hold.reset',
1865     signature => q/
1866         Un-captures and un-targets a hold, essentially returning
1867         it to the state it was in directly after it was placed,
1868         then attempts to re-target the hold
1869         @param authtoken The login session key
1870         @param holdid The id of the hold
1871     /
1872 );
1873
1874
1875 sub reset_hold {
1876     my( $self, $conn, $auth, $holdid ) = @_;
1877     my $reqr;
1878     my ($hold, $evt) = $U->fetch_hold($holdid);
1879     return $evt if $evt;
1880     ($reqr, $evt) = $U->checksesperm($auth, 'UPDATE_HOLD');
1881     return $evt if $evt;
1882     $evt = _reset_hold($self, $reqr, $hold);
1883     return $evt if $evt;
1884     return 1;
1885 }
1886
1887
1888 __PACKAGE__->register_method(
1889     method   => 'reset_hold_batch',
1890     api_name => 'open-ils.circ.hold.reset.batch'
1891 );
1892
1893 sub reset_hold_batch {
1894     my($self, $conn, $auth, $hold_ids) = @_;
1895
1896     my $e = new_editor(authtoken => $auth);
1897     return $e->event unless $e->checkauth;
1898
1899     for my $hold_id ($hold_ids) {
1900
1901         my $hold = $e->retrieve_action_hold_request(
1902             [$hold_id, {flesh => 1, flesh_fields => {ahr => ['usr']}}])
1903             or return $e->event;
1904
1905         next unless $e->allowed('UPDATE_HOLD', $hold->usr->home_ou);
1906         _reset_hold($self, $e->requestor, $hold);
1907     }
1908
1909     return 1;
1910 }
1911
1912
1913 sub _reset_hold {
1914     my ($self, $reqr, $hold) = @_;
1915
1916     my $e = new_editor(xact =>1, requestor => $reqr);
1917
1918     $logger->info("reseting hold ".$hold->id);
1919
1920     my $hid = $hold->id;
1921
1922     if( $hold->capture_time and $hold->current_copy ) {
1923
1924         my $copy = $e->retrieve_asset_copy($hold->current_copy)
1925             or return $e->die_event;
1926
1927         if( $copy->status == OILS_COPY_STATUS_ON_HOLDS_SHELF ) {
1928             $logger->info("setting copy to status 'reshelving' on hold retarget");
1929             $copy->status(OILS_COPY_STATUS_RESHELVING);
1930             $copy->editor($e->requestor->id);
1931             $copy->edit_date('now');
1932             $e->update_asset_copy($copy) or return $e->die_event;
1933
1934         } elsif( $copy->status == OILS_COPY_STATUS_IN_TRANSIT ) {
1935
1936             # We don't want the copy to remain "in transit"
1937             $copy->status(OILS_COPY_STATUS_RESHELVING);
1938             $logger->warn("! reseting hold [$hid] that is in transit");
1939             my $transid = $e->search_action_hold_transit_copy({hold=>$hold->id},{idlist=>1})->[0];
1940
1941             if( $transid ) {
1942                 my $trans = $e->retrieve_action_transit_copy($transid);
1943                 if( $trans ) {
1944                     $logger->info("Aborting transit [$transid] on hold [$hid] reset...");
1945                     my $evt = OpenILS::Application::Circ::Transit::__abort_transit($e, $trans, $copy, 1, 1);
1946                     $logger->info("Transit abort completed with result $evt");
1947                     unless ("$evt" eq 1) {
1948                         $e->rollback;
1949                         return $evt;
1950                     }
1951                 }
1952             }
1953         }
1954     }
1955
1956     $hold->clear_capture_time;
1957     $hold->clear_current_copy;
1958     $hold->clear_shelf_time;
1959     $hold->clear_shelf_expire_time;
1960     $hold->clear_current_shelf_lib;
1961
1962     $e->update_action_hold_request($hold) or return $e->die_event;
1963     $e->commit;
1964
1965     $U->storagereq(
1966         'open-ils.storage.action.hold_request.copy_targeter', undef, $hold->id );
1967
1968     return undef;
1969 }
1970
1971
1972 __PACKAGE__->register_method(
1973     method    => 'fetch_open_title_holds',
1974     api_name  => 'open-ils.circ.open_holds.retrieve',
1975     signature => q/
1976         Returns a list ids of un-fulfilled holds for a given title id
1977         @param authtoken The login session key
1978         @param id the id of the item whose holds we want to retrieve
1979         @param type The hold type - M, T, I, V, C, F, R
1980     /
1981 );
1982
1983 sub fetch_open_title_holds {
1984     my( $self, $conn, $auth, $id, $type, $org ) = @_;
1985     my $e = new_editor( authtoken => $auth );
1986     return $e->event unless $e->checkauth;
1987
1988     $type ||= "T";
1989     $org  ||= $e->requestor->ws_ou;
1990
1991 #    return $e->search_action_hold_request(
1992 #        { target => $id, hold_type => $type, fulfillment_time => undef }, {idlist=>1});
1993
1994     # XXX make me return IDs in the future ^--
1995     my $holds = $e->search_action_hold_request(
1996         {
1997             target           => $id,
1998             cancel_time      => undef,
1999             hold_type        => $type,
2000             fulfillment_time => undef
2001         }
2002     );
2003
2004     flesh_hold_transits($holds);
2005     return $holds;
2006 }
2007
2008
2009 sub flesh_hold_transits {
2010     my $holds = shift;
2011     for my $hold ( @$holds ) {
2012         $hold->transit(
2013             $apputils->simplereq(
2014                 'open-ils.cstore',
2015                 "open-ils.cstore.direct.action.hold_transit_copy.search.atomic",
2016                 { hold => $hold->id },
2017                 { order_by => { ahtc => 'id desc' }, limit => 1 }
2018             )->[0]
2019         );
2020     }
2021 }
2022
2023 sub flesh_hold_notices {
2024     my( $holds, $e ) = @_;
2025     $e ||= new_editor();
2026
2027     for my $hold (@$holds) {
2028         my $notices = $e->search_action_hold_notification(
2029             [
2030                 { hold => $hold->id },
2031                 { order_by => { anh => 'notify_time desc' } },
2032             ],
2033             {idlist=>1}
2034         );
2035
2036         $hold->notify_count(scalar(@$notices));
2037         if( @$notices ) {
2038             my $n = $e->retrieve_action_hold_notification($$notices[0])
2039                 or return $e->event;
2040             $hold->notify_time($n->notify_time);
2041         }
2042     }
2043 }
2044
2045
2046 __PACKAGE__->register_method(
2047     method    => 'fetch_captured_holds',
2048     api_name  => 'open-ils.circ.captured_holds.on_shelf.retrieve',
2049     stream    => 1,
2050     authoritative => 1,
2051     signature => q/
2052         Returns a list of un-fulfilled holds (on the Holds Shelf) for a given title id
2053         @param authtoken The login session key
2054         @param org The org id of the location in question
2055         @param match_copy A specific copy to limit to
2056     /
2057 );
2058
2059 __PACKAGE__->register_method(
2060     method    => 'fetch_captured_holds',
2061     api_name  => 'open-ils.circ.captured_holds.id_list.on_shelf.retrieve',
2062     stream    => 1,
2063     authoritative => 1,
2064     signature => q/
2065         Returns list ids of un-fulfilled holds (on the Holds Shelf) for a given title id
2066         @param authtoken The login session key
2067         @param org The org id of the location in question
2068         @param match_copy A specific copy to limit to
2069     /
2070 );
2071
2072 __PACKAGE__->register_method(
2073     method    => 'fetch_captured_holds',
2074     api_name  => 'open-ils.circ.captured_holds.id_list.expired_on_shelf.retrieve',
2075     stream    => 1,
2076     authoritative => 1,
2077     signature => q/
2078         Returns list ids of shelf-expired un-fulfilled holds for a given title id
2079         @param authtoken The login session key
2080         @param org The org id of the location in question
2081         @param match_copy A specific copy to limit to
2082     /
2083 );
2084
2085 __PACKAGE__->register_method(
2086     method    => 'fetch_captured_holds',
2087     api_name  => 
2088       'open-ils.circ.captured_holds.expired_on_shelf_or_wrong_shelf.retrieve',
2089     stream    => 1,
2090     authoritative => 1,
2091     signature => q/
2092         Returns list of shelf-expired un-fulfilled holds OR wrong shelf holds
2093         for a given shelf lib
2094     /
2095 );
2096
2097 __PACKAGE__->register_method(
2098     method    => 'fetch_captured_holds',
2099     api_name  => 
2100       'open-ils.circ.captured_holds.id_list.expired_on_shelf_or_wrong_shelf.retrieve',
2101     stream    => 1,
2102     authoritative => 1,
2103     signature => q/
2104         Returns list of shelf-expired un-fulfilled holds OR wrong shelf holds
2105         for a given shelf lib
2106     /
2107 );
2108
2109
2110 sub fetch_captured_holds {
2111     my( $self, $conn, $auth, $org, $match_copy ) = @_;
2112
2113     my $e = new_editor(authtoken => $auth);
2114     return $e->die_event unless $e->checkauth;
2115     return $e->die_event unless $e->allowed('VIEW_HOLD'); # XXX rely on editor perm
2116
2117     $org ||= $e->requestor->ws_ou;
2118
2119     my $current_copy = { '!=' => undef };
2120     $current_copy = { '=' => $match_copy } if $match_copy;
2121
2122     my $query = {
2123         select => { alhr => ['id'] },
2124         from   => {
2125             alhr => {
2126                 acp => {
2127                     field => 'id',
2128                     fkey  => 'current_copy'
2129                 },
2130             }
2131         },
2132         where => {
2133             '+acp' => { status => OILS_COPY_STATUS_ON_HOLDS_SHELF, deleted => 'f' },
2134             '+alhr' => {
2135                 capture_time      => { "!=" => undef },
2136                 current_copy      => $current_copy,
2137                 fulfillment_time  => undef,
2138                 current_shelf_lib => $org
2139             }
2140         }
2141     };
2142     if($self->api_name =~ /expired/) {
2143         $query->{'where'}->{'+alhr'}->{'-or'} = {
2144                 shelf_expire_time => { '<' => 'now'},
2145                 cancel_time => { '!=' => undef },
2146         };
2147     }
2148     my $hold_ids = $e->json_query( $query );
2149
2150     if ($self->api_name =~ /wrong_shelf/) {
2151         # fetch holds whose current_shelf_lib is $org, but whose pickup 
2152         # lib is some other org unit.  Ignore already-retrieved holds.
2153         my $wrong_shelf =
2154             pickup_lib_changed_on_shelf_holds(
2155                 $e, $org, [map {$_->{id}} @$hold_ids]);
2156         # match the layout of other items in $hold_ids
2157         push (@$hold_ids, {id => $_}) for @$wrong_shelf;
2158     }
2159
2160
2161     for my $hold_id (@$hold_ids) {
2162         if($self->api_name =~ /id_list/) {
2163             $conn->respond($hold_id->{id});
2164             next;
2165         } else {
2166             $conn->respond(
2167                 $e->retrieve_action_hold_request([
2168                     $hold_id->{id},
2169                     {
2170                         flesh => 1,
2171                         flesh_fields => {ahr => ['notifications', 'transit', 'notes']},
2172                         order_by => {anh => 'notify_time desc'}
2173                     }
2174                 ])
2175             );
2176         }
2177     }
2178
2179     return undef;
2180 }
2181
2182 __PACKAGE__->register_method(
2183     method    => "print_expired_holds_stream",
2184     api_name  => "open-ils.circ.captured_holds.expired.print.stream",
2185     stream    => 1
2186 );
2187
2188 sub print_expired_holds_stream {
2189     my ($self, $client, $auth, $params) = @_;
2190
2191     # No need to check specific permissions: we're going to call another method
2192     # that will do that.
2193     my $e = new_editor("authtoken" => $auth);
2194     return $e->die_event unless $e->checkauth;
2195
2196     delete($$params{org_id}) unless (int($$params{org_id}));
2197     delete($$params{limit}) unless (int($$params{limit}));
2198     delete($$params{offset}) unless (int($$params{offset}));
2199     delete($$params{chunk_size}) unless (int($$params{chunk_size}));
2200     delete($$params{chunk_size}) if  ($$params{chunk_size} && $$params{chunk_size} > 50); # keep the size reasonable
2201     $$params{chunk_size} ||= 10;
2202
2203     $$params{org_id} = (defined $$params{org_id}) ? $$params{org_id}: $e->requestor->ws_ou;
2204
2205     my @hold_ids = $self->method_lookup(
2206         "open-ils.circ.captured_holds.id_list.expired_on_shelf.retrieve"
2207     )->run($auth, $params->{"org_id"});
2208
2209     if (!@hold_ids) {
2210         $e->disconnect;
2211         return;
2212     } elsif (defined $U->event_code($hold_ids[0])) {
2213         $e->disconnect;
2214         return $hold_ids[0];
2215     }
2216
2217     $logger->info("about to stream back up to " . scalar(@hold_ids) . " expired holds");
2218
2219     while (@hold_ids) {
2220         my @hid_chunk = splice @hold_ids, 0, $params->{"chunk_size"};
2221
2222         my $result_chunk = $e->json_query({
2223             "select" => {
2224                 "acp" => ["barcode"],
2225                 "au" => [qw/
2226                     first_given_name second_given_name family_name alias
2227                 /],
2228                 "acn" => ["label"],
2229                 "bre" => ["marc"],
2230                 "acpl" => ["name"]
2231             },
2232             "from" => {
2233                 "ahr" => {
2234                     "acp" => {
2235                         "field" => "id", "fkey" => "current_copy",
2236                         "join" => {
2237                             "acn" => {
2238                                 "field" => "id", "fkey" => "call_number",
2239                                 "join" => {
2240                                     "bre" => {
2241                                         "field" => "id", "fkey" => "record"
2242                                     }
2243                                 }
2244                             },
2245                             "acpl" => {"field" => "id", "fkey" => "location"}
2246                         }
2247                     },
2248                     "au" => {"field" => "id", "fkey" => "usr"}
2249                 }
2250             },
2251             "where" => {"+ahr" => {"id" => \@hid_chunk}}
2252         }) or return $e->die_event;
2253         $client->respond($result_chunk);
2254     }
2255
2256     $e->disconnect;
2257     undef;
2258 }
2259
2260 __PACKAGE__->register_method(
2261     method    => "check_title_hold_batch",
2262     api_name  => "open-ils.circ.title_hold.is_possible.batch",
2263     stream    => 1,
2264     signature => {
2265         desc  => '@see open-ils.circ.title_hold.is_possible.batch',
2266         params => [
2267             { desc => 'Authentication token',     type => 'string'},
2268             { desc => 'Array of Hash of named parameters', type => 'array'},
2269         ],
2270         return => {
2271             desc => 'Array of response objects',
2272             type => 'array'
2273         }
2274     }
2275 );
2276
2277 sub check_title_hold_batch {
2278     my($self, $client, $authtoken, $param_list, $oargs) = @_;
2279     foreach (@$param_list) {
2280         my ($res) = $self->method_lookup('open-ils.circ.title_hold.is_possible')->run($authtoken, $_, $oargs);
2281         $client->respond($res);
2282     }
2283     return undef;
2284 }
2285
2286
2287 __PACKAGE__->register_method(
2288     method    => "check_title_hold",
2289     api_name  => "open-ils.circ.title_hold.is_possible",
2290     signature => {
2291         desc  => 'Determines if a hold were to be placed by a given user, ' .
2292              'whether or not said hold would have any potential copies to fulfill it.' .
2293              'The named paramaters of the second argument include: ' .
2294              'patronid, titleid, volume_id, copy_id, mrid, depth, pickup_lib, hold_type, selection_ou. ' .
2295              'See perldoc ' . __PACKAGE__ . ' for more info on these fields.' ,
2296         params => [
2297             { desc => 'Authentication token',     type => 'string'},
2298             { desc => 'Hash of named parameters', type => 'object'},
2299         ],
2300         return => {
2301             desc => 'List of new message IDs (empty if none)',
2302             type => 'array'
2303         }
2304     }
2305 );
2306
2307 =head3 check_title_hold (token, hash)
2308
2309 The named fields in the hash are:
2310
2311  patronid     - ID of the hold recipient  (required)
2312  depth        - hold range depth          (default 0)
2313  pickup_lib   - destination for hold, fallback value for selection_ou
2314  selection_ou - ID of org_unit establishing hard and soft hold boundary settings
2315  issuanceid   - ID of the issuance to be held, required for Issuance level hold
2316  partid       - ID of the monograph part to be held, required for monograph part level hold
2317  titleid      - ID (BRN) of the title to be held, required for Title level hold
2318  volume_id    - required for Volume level hold
2319  copy_id      - required for Copy level hold
2320  mrid         - required for Meta-record level hold
2321  hold_type    - T, C (or R or F), I, V or M for Title, Copy, Issuance, Volume or Meta-record  (default "T")
2322
2323 All key/value pairs are passed on to do_possibility_checks.
2324
2325 =cut
2326
2327 # FIXME: better params checking.  what other params are required, if any?
2328 # FIXME: 3 copies of values confusing: $x, $params->{x} and $params{x}
2329 # FIXME: for example, $depth gets a default value, but then $$params{depth} is still
2330 # used in conditionals, where it may be undefined, causing a warning.
2331 # FIXME: specify proper usage/interaction of selection_ou and pickup_lib
2332
2333 sub check_title_hold {
2334     my( $self, $client, $authtoken, $params ) = @_;
2335     my $e = new_editor(authtoken=>$authtoken);
2336     return $e->event unless $e->checkauth;
2337
2338     my %params       = %$params;
2339     my $depth        = $params{depth}        || 0;
2340     my $selection_ou = $params{selection_ou} || $params{pickup_lib};
2341     my $oargs        = $params{oargs}        || {};
2342
2343     if($oargs->{events}) {
2344         @{$oargs->{events}} = grep { $e->allowed($_ . '.override', $e->requestor->ws_ou); } @{$oargs->{events}};
2345     }
2346
2347
2348     my $patron = $e->retrieve_actor_user($params{patronid})
2349         or return $e->event;
2350
2351     if( $e->requestor->id ne $patron->id ) {
2352         return $e->event unless
2353             $e->allowed('VIEW_HOLD_PERMIT', $patron->home_ou);
2354     }
2355
2356     return OpenILS::Event->new('PATRON_BARRED') if $U->is_true($patron->barred);
2357
2358     my $request_lib = $e->retrieve_actor_org_unit($e->requestor->ws_ou)
2359         or return $e->event;
2360
2361     my $soft_boundary = $U->ou_ancestor_setting_value($selection_ou, OILS_SETTING_HOLD_SOFT_BOUNDARY);
2362     my $hard_boundary = $U->ou_ancestor_setting_value($selection_ou, OILS_SETTING_HOLD_HARD_BOUNDARY);
2363
2364     my @status = ();
2365     my $return_depth = $hard_boundary; # default depth to return on success
2366     if(defined $soft_boundary and $depth < $soft_boundary) {
2367         # work up the tree and as soon as we find a potential copy, use that depth
2368         # also, make sure we don't go past the hard boundary if it exists
2369
2370         # our min boundary is the greater of user-specified boundary or hard boundary
2371         my $min_depth = (defined $hard_boundary and $hard_boundary > $depth) ?
2372             $hard_boundary : $depth;
2373
2374         my $depth = $soft_boundary;
2375         while($depth >= $min_depth) {
2376             $logger->info("performing hold possibility check with soft boundary $depth");
2377             @status = do_possibility_checks($e, $patron, $request_lib, $depth, %params);
2378             if ($status[0]) {
2379                 $return_depth = $depth;
2380                 last;
2381             }
2382             $depth--;
2383         }
2384     } elsif(defined $hard_boundary and $depth < $hard_boundary) {
2385         # there is no soft boundary, enforce the hard boundary if it exists
2386         $logger->info("performing hold possibility check with hard boundary $hard_boundary");
2387         @status = do_possibility_checks($e, $patron, $request_lib, $hard_boundary, %params);
2388     } else {
2389         # no boundaries defined, fall back to user specifed boundary or no boundary
2390         $logger->info("performing hold possibility check with no boundary");
2391         @status = do_possibility_checks($e, $patron, $request_lib, $params{depth}, %params);
2392     }
2393
2394     my $place_unfillable = 0;
2395     $place_unfillable = 1 if $e->allowed('PLACE_UNFILLABLE_HOLD', $e->requestor->ws_ou);
2396
2397     if ($status[0]) {
2398         return {
2399             "success" => 1,
2400             "depth" => $return_depth,
2401             "local_avail" => $status[1]
2402         };
2403     } elsif ($status[2]) {
2404         my $n = scalar @{$status[2]};
2405         return {"success" => 0, "last_event" => $status[2]->[$n - 1], "age_protected_copy" => $status[3], "place_unfillable" => $place_unfillable};
2406     } else {
2407         return {"success" => 0, "age_protected_copy" => $status[3], "place_unfillable" => $place_unfillable};
2408     }
2409 }
2410
2411
2412
2413 sub do_possibility_checks {
2414     my($e, $patron, $request_lib, $depth, %params) = @_;
2415
2416     my $issuanceid   = $params{issuanceid}      || "";
2417     my $partid       = $params{partid}      || "";
2418     my $titleid      = $params{titleid}      || "";
2419     my $volid        = $params{volume_id};
2420     my $copyid       = $params{copy_id};
2421     my $mrid         = $params{mrid}         || "";
2422     my $pickup_lib   = $params{pickup_lib};
2423     my $hold_type    = $params{hold_type}    || 'T';
2424     my $selection_ou = $params{selection_ou} || $pickup_lib;
2425     my $holdable_formats = $params{holdable_formats};
2426     my $oargs        = $params{oargs}        || {};
2427
2428
2429     my $copy;
2430     my $volume;
2431     my $title;
2432
2433     if( $hold_type eq OILS_HOLD_TYPE_FORCE || $hold_type eq OILS_HOLD_TYPE_RECALL || $hold_type eq OILS_HOLD_TYPE_COPY ) {
2434
2435         return $e->event unless $copy   = $e->retrieve_asset_copy($copyid);
2436         return $e->event unless $volume = $e->retrieve_asset_call_number($copy->call_number);
2437         return $e->event unless $title  = $e->retrieve_biblio_record_entry($volume->record);
2438
2439         return (1, 1, []) if( $hold_type eq OILS_HOLD_TYPE_RECALL || $hold_type eq OILS_HOLD_TYPE_FORCE);
2440         return verify_copy_for_hold(
2441             $patron, $e->requestor, $title, $copy, $pickup_lib, $request_lib, $oargs
2442         );
2443
2444     } elsif( $hold_type eq OILS_HOLD_TYPE_VOLUME ) {
2445
2446         return $e->event unless $volume = $e->retrieve_asset_call_number($volid);
2447         return $e->event unless $title  = $e->retrieve_biblio_record_entry($volume->record);
2448
2449         return _check_volume_hold_is_possible(
2450             $volume, $title, $depth, $request_lib, $patron, $e->requestor, $pickup_lib, $selection_ou, $oargs
2451         );
2452
2453     } elsif( $hold_type eq OILS_HOLD_TYPE_TITLE ) {
2454
2455         return _check_title_hold_is_possible(
2456             $titleid, $depth, $request_lib, $patron, $e->requestor, $pickup_lib, $selection_ou, undef, $oargs
2457         );
2458
2459     } elsif( $hold_type eq OILS_HOLD_TYPE_ISSUANCE ) {
2460
2461         return _check_issuance_hold_is_possible(
2462             $issuanceid, $depth, $request_lib, $patron, $e->requestor, $pickup_lib, $selection_ou, $oargs
2463         );
2464
2465     } elsif( $hold_type eq OILS_HOLD_TYPE_MONOPART ) {
2466
2467         return _check_monopart_hold_is_possible(
2468             $partid, $depth, $request_lib, $patron, $e->requestor, $pickup_lib, $selection_ou, $oargs
2469         );
2470
2471     } elsif( $hold_type eq OILS_HOLD_TYPE_METARECORD ) {
2472
2473         my $maps = $e->search_metabib_metarecord_source_map({metarecord=>$mrid});
2474         my @recs = map { $_->source } @$maps;
2475         my @status = ();
2476         for my $rec (@recs) {
2477             @status = _check_title_hold_is_possible(
2478                 $rec, $depth, $request_lib, $patron, $e->requestor, $pickup_lib, $selection_ou, $holdable_formats, $oargs
2479             );
2480             last if $status[0];
2481         }
2482         return @status;
2483     }
2484 #   else { Unrecognized hold_type ! }   # FIXME: return error? or 0?
2485 }
2486
2487 my %prox_cache;
2488 sub create_ranged_org_filter {
2489     my($e, $selection_ou, $depth) = @_;
2490
2491     # find the orgs from which this hold may be fulfilled,
2492     # based on the selection_ou and depth
2493
2494     my $top_org = $e->search_actor_org_unit([
2495         {parent_ou => undef},
2496         {flesh=>1, flesh_fields=>{aou=>['ou_type']}}])->[0];
2497     my %org_filter;
2498
2499     return () if $depth == $top_org->ou_type->depth;
2500
2501     my $org_list = $U->storagereq('open-ils.storage.actor.org_unit.descendants.atomic', $selection_ou, $depth);
2502     %org_filter = (circ_lib => []);
2503     push(@{$org_filter{circ_lib}}, $_->id) for @$org_list;
2504
2505     $logger->info("hold org filter at depth $depth and selection_ou ".
2506         "$selection_ou created list of @{$org_filter{circ_lib}}");
2507
2508     return %org_filter;
2509 }
2510
2511
2512 sub _check_title_hold_is_possible {
2513     my( $titleid, $depth, $request_lib, $patron, $requestor, $pickup_lib, $selection_ou, $holdable_formats, $oargs ) = @_;
2514
2515     my ($types, $formats, $lang);
2516     if (defined($holdable_formats)) {
2517         ($types, $formats, $lang) = split '-', $holdable_formats;
2518     }
2519
2520     my $e = new_editor();
2521     my %org_filter = create_ranged_org_filter($e, $selection_ou, $depth);
2522
2523     # this monster will grab the id and circ_lib of all of the "holdable" copies for the given record
2524     my $copies = $e->json_query(
2525         {
2526             select => { acp => ['id', 'circ_lib'] },
2527               from => {
2528                 acp => {
2529                     acn => {
2530                         field  => 'id',
2531                         fkey   => 'call_number',
2532                         'join' => {
2533                             bre => {
2534                                 field  => 'id',
2535                                 filter => { id => $titleid },
2536                                 fkey   => 'record'
2537                             },
2538                             mrd => {
2539                                 field  => 'record',
2540                                 fkey   => 'record',
2541                                 filter => {
2542                                     record => $titleid,
2543                                     ( $types   ? (item_type => [split '', $types])   : () ),
2544                                     ( $formats ? (item_form => [split '', $formats]) : () ),
2545                                     ( $lang    ? (item_lang => $lang)                : () )
2546                                 }
2547                             }
2548                         }
2549                     },
2550                     acpl => { field => 'id', filter => { holdable => 't'}, fkey => 'location' },
2551                     ccs  => { field => 'id', filter => { holdable => 't'}, fkey => 'status'   },
2552                     acpm => { field => 'target_copy', type => 'left' } # ignore part-linked copies
2553                 }
2554             },
2555             where => {
2556                 '+acp' => { circulate => 't', deleted => 'f', holdable => 't', %org_filter },
2557                 '+acpm' => { target_copy => undef } # ignore part-linked copies
2558             }
2559         }
2560     );
2561
2562     $logger->info("title possible found ".scalar(@$copies)." potential copies");
2563     return (
2564         0, 0, [
2565             new OpenILS::Event(
2566                 "HIGH_LEVEL_HOLD_HAS_NO_COPIES",
2567                 "payload" => {"fail_part" => "no_ultimate_items"}
2568             )
2569         ]
2570     ) unless @$copies;
2571
2572     # -----------------------------------------------------------------------
2573     # sort the copies into buckets based on their circ_lib proximity to
2574     # the patron's home_ou.
2575     # -----------------------------------------------------------------------
2576
2577     my $home_org = $patron->home_ou;
2578     my $req_org = $request_lib->id;
2579
2580     $logger->info("prox cache $home_org " . $prox_cache{$home_org});
2581
2582     $prox_cache{$home_org} =
2583         $e->search_actor_org_unit_proximity({from_org => $home_org})
2584         unless $prox_cache{$home_org};
2585     my $home_prox = $prox_cache{$home_org};
2586
2587     my %buckets;
2588     my %hash = map { ($_->to_org => $_->prox) } @$home_prox;
2589     push( @{$buckets{ $hash{$_->{circ_lib}} } }, $_->{id} ) for @$copies;
2590
2591     my @keys = sort { $a <=> $b } keys %buckets;
2592
2593
2594     if( $home_org ne $req_org ) {
2595       # -----------------------------------------------------------------------
2596       # shove the copies close to the request_lib into the primary buckets
2597       # directly before the farthest away copies.  That way, they are not
2598       # given priority, but they are checked before the farthest copies.
2599       # -----------------------------------------------------------------------
2600         $prox_cache{$req_org} =
2601             $e->search_actor_org_unit_proximity({from_org => $req_org})
2602             unless $prox_cache{$req_org};
2603         my $req_prox = $prox_cache{$req_org};
2604
2605         my %buckets2;
2606         my %hash2 = map { ($_->to_org => $_->prox) } @$req_prox;
2607         push( @{$buckets2{ $hash2{$_->{circ_lib}} } }, $_->{id} ) for @$copies;
2608
2609         my $highest_key = $keys[@keys - 1];  # the farthest prox in the exising buckets
2610         my $new_key = $highest_key - 0.5; # right before the farthest prox
2611         my @keys2   = sort { $a <=> $b } keys %buckets2;
2612         for my $key (@keys2) {
2613             last if $key >= $highest_key;
2614             push( @{$buckets{$new_key}}, $_ ) for @{$buckets2{$key}};
2615         }
2616     }
2617
2618     @keys = sort { $a <=> $b } keys %buckets;
2619
2620     my $title;
2621     my %seen;
2622     my @status;
2623     my $age_protect_only = 0;
2624     OUTER: for my $key (@keys) {
2625       my @cps = @{$buckets{$key}};
2626
2627       $logger->info("looking at " . scalar(@{$buckets{$key}}). " copies in proximity bucket $key");
2628
2629       for my $copyid (@cps) {
2630
2631          next if $seen{$copyid};
2632          $seen{$copyid} = 1; # there could be dupes given the merged buckets
2633          my $copy = $e->retrieve_asset_copy($copyid);
2634          $logger->debug("looking at bucket_key=$key, copy $copyid : circ_lib = " . $copy->circ_lib);
2635
2636          unless($title) { # grab the title if we don't already have it
2637             my $vol = $e->retrieve_asset_call_number(
2638                [ $copy->call_number, { flesh => 1, flesh_fields => { bre => ['fixed_fields'], acn => ['record'] } } ] );
2639             $title = $vol->record;
2640          }
2641
2642          @status = verify_copy_for_hold(
2643             $patron, $requestor, $title, $copy, $pickup_lib, $request_lib, $oargs);
2644
2645          $age_protect_only ||= $status[3];
2646          last OUTER if $status[0];
2647       }
2648     }
2649
2650     $status[3] = $age_protect_only;
2651     return @status;
2652 }
2653
2654 sub _check_issuance_hold_is_possible {
2655     my( $issuanceid, $depth, $request_lib, $patron, $requestor, $pickup_lib, $selection_ou, $oargs ) = @_;
2656
2657     my $e = new_editor();
2658     my %org_filter = create_ranged_org_filter($e, $selection_ou, $depth);
2659
2660     # this monster will grab the id and circ_lib of all of the "holdable" copies for the given record
2661     my $copies = $e->json_query(
2662         {
2663             select => { acp => ['id', 'circ_lib'] },
2664               from => {
2665                 acp => {
2666                     sitem => {
2667                         field  => 'unit',
2668                         fkey   => 'id',
2669                         filter => { issuance => $issuanceid }
2670                     },
2671                     acpl => { field => 'id', filter => { holdable => 't'}, fkey => 'location' },
2672                     ccs  => { field => 'id', filter => { holdable => 't'}, fkey => 'status'   }
2673                 }
2674             },
2675             where => {
2676                 '+acp' => { circulate => 't', deleted => 'f', holdable => 't', %org_filter }
2677             },
2678             distinct => 1
2679         }
2680     );
2681
2682     $logger->info("issuance possible found ".scalar(@$copies)." potential copies");
2683
2684     my $empty_ok;
2685     if (!@$copies) {
2686         $empty_ok = $e->retrieve_config_global_flag('circ.holds.empty_issuance_ok');
2687         $empty_ok = ($empty_ok and $U->is_true($empty_ok->enabled));
2688
2689         return (
2690             0, 0, [
2691                 new OpenILS::Event(
2692                     "HIGH_LEVEL_HOLD_HAS_NO_COPIES",
2693                     "payload" => {"fail_part" => "no_ultimate_items"}
2694                 )
2695             ]
2696         ) unless $empty_ok;
2697
2698         return (1, 0);
2699     }
2700
2701     # -----------------------------------------------------------------------
2702     # sort the copies into buckets based on their circ_lib proximity to
2703     # the patron's home_ou.
2704     # -----------------------------------------------------------------------
2705
2706     my $home_org = $patron->home_ou;
2707     my $req_org = $request_lib->id;
2708
2709     $logger->info("prox cache $home_org " . $prox_cache{$home_org});
2710
2711     $prox_cache{$home_org} =
2712         $e->search_actor_org_unit_proximity({from_org => $home_org})
2713         unless $prox_cache{$home_org};
2714     my $home_prox = $prox_cache{$home_org};
2715
2716     my %buckets;
2717     my %hash = map { ($_->to_org => $_->prox) } @$home_prox;
2718     push( @{$buckets{ $hash{$_->{circ_lib}} } }, $_->{id} ) for @$copies;
2719
2720     my @keys = sort { $a <=> $b } keys %buckets;
2721
2722
2723     if( $home_org ne $req_org ) {
2724       # -----------------------------------------------------------------------
2725       # shove the copies close to the request_lib into the primary buckets
2726       # directly before the farthest away copies.  That way, they are not
2727       # given priority, but they are checked before the farthest copies.
2728       # -----------------------------------------------------------------------
2729         $prox_cache{$req_org} =
2730             $e->search_actor_org_unit_proximity({from_org => $req_org})
2731             unless $prox_cache{$req_org};
2732         my $req_prox = $prox_cache{$req_org};
2733
2734         my %buckets2;
2735         my %hash2 = map { ($_->to_org => $_->prox) } @$req_prox;
2736         push( @{$buckets2{ $hash2{$_->{circ_lib}} } }, $_->{id} ) for @$copies;
2737
2738         my $highest_key = $keys[@keys - 1];  # the farthest prox in the exising buckets
2739         my $new_key = $highest_key - 0.5; # right before the farthest prox
2740         my @keys2   = sort { $a <=> $b } keys %buckets2;
2741         for my $key (@keys2) {
2742             last if $key >= $highest_key;
2743             push( @{$buckets{$new_key}}, $_ ) for @{$buckets2{$key}};
2744         }
2745     }
2746
2747     @keys = sort { $a <=> $b } keys %buckets;
2748
2749     my $title;
2750     my %seen;
2751     my @status;
2752     my $age_protect_only = 0;
2753     OUTER: for my $key (@keys) {
2754       my @cps = @{$buckets{$key}};
2755
2756       $logger->info("looking at " . scalar(@{$buckets{$key}}). " copies in proximity bucket $key");
2757
2758       for my $copyid (@cps) {
2759
2760          next if $seen{$copyid};
2761          $seen{$copyid} = 1; # there could be dupes given the merged buckets
2762          my $copy = $e->retrieve_asset_copy($copyid);
2763          $logger->debug("looking at bucket_key=$key, copy $copyid : circ_lib = " . $copy->circ_lib);
2764
2765          unless($title) { # grab the title if we don't already have it
2766             my $vol = $e->retrieve_asset_call_number(
2767                [ $copy->call_number, { flesh => 1, flesh_fields => { bre => ['fixed_fields'], acn => ['record'] } } ] );
2768             $title = $vol->record;
2769          }
2770
2771          @status = verify_copy_for_hold(
2772             $patron, $requestor, $title, $copy, $pickup_lib, $request_lib, $oargs);
2773
2774          $age_protect_only ||= $status[3];
2775          last OUTER if $status[0];
2776       }
2777     }
2778
2779     if (!$status[0]) {
2780         if (!defined($empty_ok)) {
2781             $empty_ok = $e->retrieve_config_global_flag('circ.holds.empty_issuance_ok');
2782             $empty_ok = ($empty_ok and $U->is_true($empty_ok->enabled));
2783         }
2784
2785         return (1,0) if ($empty_ok);
2786     }
2787     $status[3] = $age_protect_only;
2788     return @status;
2789 }
2790
2791 sub _check_monopart_hold_is_possible {
2792     my( $partid, $depth, $request_lib, $patron, $requestor, $pickup_lib, $selection_ou, $oargs ) = @_;
2793
2794     my $e = new_editor();
2795     my %org_filter = create_ranged_org_filter($e, $selection_ou, $depth);
2796
2797     # this monster will grab the id and circ_lib of all of the "holdable" copies for the given record
2798     my $copies = $e->json_query(
2799         {
2800             select => { acp => ['id', 'circ_lib'] },
2801               from => {
2802                 acp => {
2803                     acpm => {
2804                         field  => 'target_copy',
2805                         fkey   => 'id',
2806                         filter => { part => $partid }
2807                     },
2808                     acpl => { field => 'id', filter => { holdable => 't'}, fkey => 'location' },
2809                     ccs  => { field => 'id', filter => { holdable => 't'}, fkey => 'status'   }
2810                 }
2811             },
2812             where => {
2813                 '+acp' => { circulate => 't', deleted => 'f', holdable => 't', %org_filter }
2814             },
2815             distinct => 1
2816         }
2817     );
2818
2819     $logger->info("monopart possible found ".scalar(@$copies)." potential copies");
2820
2821     my $empty_ok;
2822     if (!@$copies) {
2823         $empty_ok = $e->retrieve_config_global_flag('circ.holds.empty_part_ok');
2824         $empty_ok = ($empty_ok and $U->is_true($empty_ok->enabled));
2825
2826         return (
2827             0, 0, [
2828                 new OpenILS::Event(
2829                     "HIGH_LEVEL_HOLD_HAS_NO_COPIES",
2830                     "payload" => {"fail_part" => "no_ultimate_items"}
2831                 )
2832             ]
2833         ) unless $empty_ok;
2834
2835         return (1, 0);
2836     }
2837
2838     # -----------------------------------------------------------------------
2839     # sort the copies into buckets based on their circ_lib proximity to
2840     # the patron's home_ou.
2841     # -----------------------------------------------------------------------
2842
2843     my $home_org = $patron->home_ou;
2844     my $req_org = $request_lib->id;
2845
2846     $logger->info("prox cache $home_org " . $prox_cache{$home_org});
2847
2848     $prox_cache{$home_org} =
2849         $e->search_actor_org_unit_proximity({from_org => $home_org})
2850         unless $prox_cache{$home_org};
2851     my $home_prox = $prox_cache{$home_org};
2852
2853     my %buckets;
2854     my %hash = map { ($_->to_org => $_->prox) } @$home_prox;
2855     push( @{$buckets{ $hash{$_->{circ_lib}} } }, $_->{id} ) for @$copies;
2856
2857     my @keys = sort { $a <=> $b } keys %buckets;
2858
2859
2860     if( $home_org ne $req_org ) {
2861       # -----------------------------------------------------------------------
2862       # shove the copies close to the request_lib into the primary buckets
2863       # directly before the farthest away copies.  That way, they are not
2864       # given priority, but they are checked before the farthest copies.
2865       # -----------------------------------------------------------------------
2866         $prox_cache{$req_org} =
2867             $e->search_actor_org_unit_proximity({from_org => $req_org})
2868             unless $prox_cache{$req_org};
2869         my $req_prox = $prox_cache{$req_org};
2870
2871         my %buckets2;
2872         my %hash2 = map { ($_->to_org => $_->prox) } @$req_prox;
2873         push( @{$buckets2{ $hash2{$_->{circ_lib}} } }, $_->{id} ) for @$copies;
2874
2875         my $highest_key = $keys[@keys - 1];  # the farthest prox in the exising buckets
2876         my $new_key = $highest_key - 0.5; # right before the farthest prox
2877         my @keys2   = sort { $a <=> $b } keys %buckets2;
2878         for my $key (@keys2) {
2879             last if $key >= $highest_key;
2880             push( @{$buckets{$new_key}}, $_ ) for @{$buckets2{$key}};
2881         }
2882     }
2883
2884     @keys = sort { $a <=> $b } keys %buckets;
2885
2886     my $title;
2887     my %seen;
2888     my @status;
2889     my $age_protect_only = 0;
2890     OUTER: for my $key (@keys) {
2891       my @cps = @{$buckets{$key}};
2892
2893       $logger->info("looking at " . scalar(@{$buckets{$key}}). " copies in proximity bucket $key");
2894
2895       for my $copyid (@cps) {
2896
2897          next if $seen{$copyid};
2898          $seen{$copyid} = 1; # there could be dupes given the merged buckets
2899          my $copy = $e->retrieve_asset_copy($copyid);
2900          $logger->debug("looking at bucket_key=$key, copy $copyid : circ_lib = " . $copy->circ_lib);
2901
2902          unless($title) { # grab the title if we don't already have it
2903             my $vol = $e->retrieve_asset_call_number(
2904                [ $copy->call_number, { flesh => 1, flesh_fields => { bre => ['fixed_fields'], acn => ['record'] } } ] );
2905             $title = $vol->record;
2906          }
2907
2908          @status = verify_copy_for_hold(
2909             $patron, $requestor, $title, $copy, $pickup_lib, $request_lib, $oargs);
2910
2911          $age_protect_only ||= $status[3];
2912          last OUTER if $status[0];
2913       }
2914     }
2915
2916     if (!$status[0]) {
2917         if (!defined($empty_ok)) {
2918             $empty_ok = $e->retrieve_config_global_flag('circ.holds.empty_part_ok');
2919             $empty_ok = ($empty_ok and $U->is_true($empty_ok->enabled));
2920         }
2921
2922         return (1,0) if ($empty_ok);
2923     }
2924     $status[3] = $age_protect_only;
2925     return @status;
2926 }
2927
2928
2929 sub _check_volume_hold_is_possible {
2930     my( $vol, $title, $depth, $request_lib, $patron, $requestor, $pickup_lib, $selection_ou, $oargs ) = @_;
2931     my %org_filter = create_ranged_org_filter(new_editor(), $selection_ou, $depth);
2932     my $copies = new_editor->search_asset_copy({call_number => $vol->id, %org_filter});
2933     $logger->info("checking possibility of volume hold for volume ".$vol->id);
2934
2935     my $filter_copies = [];
2936     for my $copy (@$copies) {
2937         # ignore part-mapped copies for regular volume level holds
2938         push(@$filter_copies, $copy) unless
2939             new_editor->search_asset_copy_part_map({target_copy => $copy->id})->[0];
2940     }
2941     $copies = $filter_copies;
2942
2943     return (
2944         0, 0, [
2945             new OpenILS::Event(
2946                 "HIGH_LEVEL_HOLD_HAS_NO_COPIES",
2947                 "payload" => {"fail_part" => "no_ultimate_items"}
2948             )
2949         ]
2950     ) unless @$copies;
2951
2952     my @status;
2953     my $age_protect_only = 0;
2954     for my $copy ( @$copies ) {
2955         @status = verify_copy_for_hold(
2956             $patron, $requestor, $title, $copy, $pickup_lib, $request_lib, $oargs );
2957         $age_protect_only ||= $status[3];
2958         last if $status[0];
2959     }
2960     $status[3] = $age_protect_only;
2961     return @status;
2962 }
2963
2964
2965
2966 sub verify_copy_for_hold {
2967     my( $patron, $requestor, $title, $copy, $pickup_lib, $request_lib, $oargs ) = @_;
2968     # $oargs should be undef unless we're overriding.
2969     $logger->info("checking possibility of copy in hold request for copy ".$copy->id);
2970     my $permitted = OpenILS::Utils::PermitHold::permit_copy_hold(
2971         {
2972             patron           => $patron,
2973             requestor        => $requestor,
2974             copy             => $copy,
2975             title            => $title,
2976             title_descriptor => $title->fixed_fields,
2977             pickup_lib       => $pickup_lib,
2978             request_lib      => $request_lib,
2979             new_hold         => 1,
2980             show_event_list  => 1
2981         }
2982     );
2983
2984     # Check for override permissions on events.
2985     if ($oargs && $permitted && scalar @$permitted) {
2986         # Remove the events from permitted that we can override.
2987         if ($oargs->{events}) {
2988             foreach my $evt (@{$oargs->{events}}) {
2989                 $permitted = [grep {$_->{textcode} ne $evt} @{$permitted}];
2990             }
2991         }
2992         # Now, we handle the override all case by checking remaining
2993         # events against override permissions.
2994         if (scalar @$permitted && $oargs->{all}) {
2995             # Pre-set events and failed members of oargs to empty
2996             # arrays, if they are not set, yet.
2997             $oargs->{events} = [] unless ($oargs->{events});
2998             $oargs->{failed} = [] unless ($oargs->{failed});
2999             # When we're done with these checks, we swap permitted
3000             # with a reference to @disallowed.
3001             my @disallowed = ();
3002             foreach my $evt (@{$permitted}) {
3003                 # Check if we've already seen the event in this
3004                 # session and it failed.
3005                 if (grep {$_ eq $evt->{textcode}} @{$oargs->{failed}}) {
3006                     push(@disallowed, $evt);
3007                 } else {
3008                     # We have to check if the requestor has the
3009                     # override permission.
3010
3011                     # AppUtils::check_user_perms returns the perm if
3012                     # the user doesn't have it, undef if they do.
3013                     if ($apputils->check_user_perms($requestor->id, $requestor->ws_ou, $evt->{textcode} . '.override')) {
3014                         push(@disallowed, $evt);
3015                         push(@{$oargs->{failed}}, $evt->{textcode});
3016                     } else {
3017                         push(@{$oargs->{events}}, $evt->{textcode});
3018                     }
3019                 }
3020             }
3021             $permitted = \@disallowed;
3022         }
3023     }
3024
3025     my $age_protect_only = 0;
3026     if (@$permitted == 1 && @$permitted[0]->{textcode} eq 'ITEM_AGE_PROTECTED') {
3027         $age_protect_only = 1;
3028     }
3029
3030     return (
3031         (not scalar @$permitted), # true if permitted is an empty arrayref
3032         (   # XXX This test is of very dubious value; someone should figure
3033             # out what if anything is checking this value
3034             ($copy->circ_lib == $pickup_lib) and
3035             ($copy->status == OILS_COPY_STATUS_AVAILABLE)
3036         ),
3037         $permitted,
3038         $age_protect_only
3039     );
3040 }
3041
3042
3043
3044 sub find_nearest_permitted_hold {
3045
3046     my $class  = shift;
3047     my $editor = shift;     # CStoreEditor object
3048     my $copy   = shift;     # copy to target
3049     my $user   = shift;     # staff
3050     my $check_only = shift; # do no updates, just see if the copy could fulfill a hold
3051
3052     my $evt = OpenILS::Event->new('ACTION_HOLD_REQUEST_NOT_FOUND');
3053
3054     my $bc = $copy->barcode;
3055
3056     # find any existing holds that already target this copy
3057     my $old_holds = $editor->search_action_hold_request(
3058         {    current_copy => $copy->id,
3059             cancel_time  => undef,
3060             capture_time => undef
3061         }
3062     );
3063
3064     my $hold_stall_interval = $U->ou_ancestor_setting_value($user->ws_ou, OILS_SETTING_HOLD_SOFT_STALL);
3065
3066     $logger->info("circulator: searching for best hold at org ".$user->ws_ou.
3067         " and copy $bc with a hold stalling interval of ". ($hold_stall_interval || "(none)"));
3068
3069     my $fifo = $U->ou_ancestor_setting_value($user->ws_ou, 'circ.holds_fifo');
3070
3071     # the nearest_hold API call now needs this
3072     $copy->call_number($editor->retrieve_asset_call_number($copy->call_number))
3073         unless ref $copy->call_number;
3074
3075     # search for what should be the best holds for this copy to fulfill
3076     my $best_holds = $U->storagereq(
3077         "open-ils.storage.action.hold_request.nearest_hold.atomic", 
3078         $user->ws_ou, $copy, 100, $hold_stall_interval, $fifo );
3079
3080     # Add any pre-targeted holds to the list too? Unless they are already there, anyway.
3081     if ($old_holds) {
3082         for my $holdid (@$old_holds) {
3083             next unless $holdid;
3084             push(@$best_holds, $holdid) unless ( grep { ''.$holdid eq ''.$_ } @$best_holds );
3085         }
3086     }
3087
3088     unless(@$best_holds) {
3089         $logger->info("circulator: no suitable holds found for copy $bc");
3090         return (undef, $evt);
3091     }
3092
3093
3094     my $best_hold;
3095
3096     # for each potential hold, we have to run the permit script
3097     # to make sure the hold is actually permitted.
3098     my %reqr_cache;
3099     my %org_cache;
3100     for my $holdid (@$best_holds) {
3101         next unless $holdid;
3102         $logger->info("circulator:&nb