1 # ---------------------------------------------------------------
2 # Copyright (C) 2005 Georgia Public Library Service
3 # Bill Erickson <highfalutin@gmail.com>
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.
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 # ---------------------------------------------------------------
17 package OpenILS::Application::Circ::Holds;
18 use base qw/OpenILS::Application/;
19 use strict; use warnings;
20 use OpenILS::Application::AppUtils;
23 use OpenSRF::EX qw(:try);
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;
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";
43 __PACKAGE__->register_method(
44 method => "test_and_create_hold_batch",
45 api_name => "open-ils.circ.holds.test_and_create.batch",
48 desc => q/This is for batch creating a set of holds where every field is identical except for the targets./,
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' }
55 desc => 'Array of hold ID on success, -1 on missing arg, event (or ref to array of events) on error(s)',
60 __PACKAGE__->register_method(
61 method => "test_and_create_hold_batch",
62 api_name => "open-ils.circ.holds.test_and_create.batch.override",
65 desc => '@see open-ils.circ.holds.test_and_create.batch',
70 sub test_and_create_hold_batch {
71 my( $self, $conn, $auth, $params, $target_list, $oargs ) = @_;
74 if ($self->api_name =~ /override/) {
76 $oargs = { all => 1 } unless defined $oargs;
77 $$params{oargs} = $oargs; # for is_possible checking.
80 my $e = new_editor(authtoken=>$auth);
81 return $e->die_event unless $e->checkauth;
82 $$params{'requestor'} = $e->requestor->id;
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; }
95 my $formats_map = delete $$params{holdable_formats_map};
97 foreach (@$target_list) {
98 $$params{$target_field} = $_;
100 # copy the requested formats from the target->formats map
101 # into the top-level formats attr for each hold
102 $$params{holdable_formats} = $formats_map->{$_};
105 ($res) = $self->method_lookup(
106 'open-ils.circ.title_hold.is_possible')->run($auth, $params, $override ? $oargs : {});
107 if ($res->{'success'} == 1) {
109 $params->{'depth'} = $res->{'depth'} if $res->{'depth'};
111 # Remove oargs from params so holds can be created.
112 if ($$params{oargs}) {
113 delete $$params{oargs};
116 my $ahr = construct_hold_request_object($params);
117 my ($res2) = $self->method_lookup(
119 ? 'open-ils.circ.holds.create.override'
120 : 'open-ils.circ.holds.create'
121 )->run($auth, $ahr, $oargs);
123 'target' => $$params{$target_field},
126 $conn->respond($res2);
129 'target' => $$params{$target_field},
132 $conn->respond($res);
138 sub construct_hold_request_object {
141 my $ahr = Fieldmapper::action::hold_request->new;
144 foreach my $field (keys %{ $params }) {
145 if ($field eq 'depth') { $ahr->selection_depth($$params{$field}); }
146 elsif ($field eq 'patronid') {
147 $ahr->usr($$params{$field}); }
148 elsif ($field eq 'titleid') { $ahr->target($$params{$field}); }
149 elsif ($field eq 'copy_id') { $ahr->target($$params{$field}); }
150 elsif ($field eq 'issuanceid') { $ahr->target($$params{$field}); }
151 elsif ($field eq 'volume_id') { $ahr->target($$params{$field}); }
152 elsif ($field eq 'mrid') { $ahr->target($$params{$field}); }
153 elsif ($field eq 'partid') { $ahr->target($$params{$field}); }
155 $ahr->$field($$params{$field});
161 __PACKAGE__->register_method(
162 method => "create_hold_batch",
163 api_name => "open-ils.circ.holds.create.batch",
166 desc => q/@see open-ils.circ.holds.create.batch/,
168 { desc => 'Authentication token', type => 'string' },
169 { desc => 'Array of hold objects', type => 'array' }
172 desc => 'Array of hold ID on success, -1 on missing arg, event (or ref to array of events) on error(s)',
177 __PACKAGE__->register_method(
178 method => "create_hold_batch",
179 api_name => "open-ils.circ.holds.create.override.batch",
182 desc => '@see open-ils.circ.holds.create.batch',
187 sub create_hold_batch {
188 my( $self, $conn, $auth, $hold_list, $oargs ) = @_;
189 (my $method = $self->api_name) =~ s/\.batch//og;
190 foreach (@$hold_list) {
191 my ($res) = $self->method_lookup($method)->run($auth, $_, $oargs);
192 $conn->respond($res);
198 __PACKAGE__->register_method(
199 method => "create_hold",
200 api_name => "open-ils.circ.holds.create",
202 desc => "Create a new hold for an item. From a permissions perspective, " .
203 "the login session is used as the 'requestor' of the hold. " .
204 "The hold recipient is determined by the 'usr' setting within the hold object. " .
205 'First we verify the requestor has holds request permissions. ' .
206 'Then we verify that the recipient is allowed to make the given hold. ' .
207 'If not, we see if the requestor has "override" capabilities. If not, ' .
208 'a permission exception is returned. If permissions allow, we cycle ' .
209 'through the set of holds objects and create. ' .
210 'If the recipient does not have permission to place multiple holds ' .
211 'on a single title and said operation is attempted, a permission ' .
212 'exception is returned',
214 { desc => 'Authentication token', type => 'string' },
215 { desc => 'Hold object for hold to be created',
216 type => 'object', class => 'ahr' }
219 desc => 'New ahr ID on success, -1 on missing arg, event (or ref to array of events) on error(s)',
224 __PACKAGE__->register_method(
225 method => "create_hold",
226 api_name => "open-ils.circ.holds.create.override",
227 notes => '@see open-ils.circ.holds.create',
229 desc => "If the recipient is not allowed to receive the requested hold, " .
230 "call this method to attempt the override",
232 { desc => 'Authentication token', type => 'string' },
234 desc => 'Hold object for hold to be created',
235 type => 'object', class => 'ahr'
239 desc => 'New hold (ahr) ID on success, -1 on missing arg, event (or ref to array of events) on error(s)',
245 my( $self, $conn, $auth, $hold, $oargs ) = @_;
246 return -1 unless $hold;
247 my $e = new_editor(authtoken=>$auth, xact=>1);
248 return $e->die_event unless $e->checkauth;
251 if ($self->api_name =~ /override/) {
253 $oargs = { all => 1 } unless defined $oargs;
258 my $requestor = $e->requestor;
259 my $recipient = $requestor;
261 if( $requestor->id ne $hold->usr ) {
262 # Make sure the requestor is allowed to place holds for
263 # the recipient if they are not the same people
264 $recipient = $e->retrieve_actor_user($hold->usr) or return $e->die_event;
265 $e->allowed('REQUEST_HOLDS', $recipient->home_ou) or return $e->die_event;
268 # If the related org setting tells us to, block if patron privs have expired
269 my $expire_setting = $U->ou_ancestor_setting_value($recipient->home_ou, OILS_SETTING_BLOCK_HOLD_FOR_EXPIRED_PATRON);
270 if ($expire_setting) {
271 my $expire = DateTime::Format::ISO8601->new->parse_datetime(
272 cleanse_ISO8601($recipient->expire_date));
274 push( @events, OpenILS::Event->new(
275 'PATRON_ACCOUNT_EXPIRED',
276 "payload" => {"fail_part" => "actor.usr.privs_expired"}
277 )) if( CORE::time > $expire->epoch ) ;
280 # Now make sure the recipient is allowed to receive the specified hold
281 my $porg = $recipient->home_ou;
282 my $rid = $e->requestor->id;
283 my $t = $hold->hold_type;
285 # See if a duplicate hold already exists
287 usr => $recipient->id,
289 fulfillment_time => undef,
290 target => $hold->target,
291 cancel_time => undef,
294 $sargs->{holdable_formats} = $hold->holdable_formats if $t eq 'M';
296 my $existing = $e->search_action_hold_request($sargs);
297 push( @events, OpenILS::Event->new('HOLD_EXISTS')) if @$existing;
299 my $checked_out = hold_item_is_checked_out($e, $recipient->id, $hold->hold_type, $hold->target);
300 push( @events, OpenILS::Event->new('HOLD_ITEM_CHECKED_OUT')) if $checked_out;
302 if ( $t eq OILS_HOLD_TYPE_METARECORD ) {
303 return $e->die_event unless $e->allowed('MR_HOLDS', $porg);
304 } elsif ( $t eq OILS_HOLD_TYPE_TITLE ) {
305 return $e->die_event unless $e->allowed('TITLE_HOLDS', $porg);
306 } elsif ( $t eq OILS_HOLD_TYPE_VOLUME ) {
307 return $e->die_event unless $e->allowed('VOLUME_HOLDS', $porg);
308 } elsif ( $t eq OILS_HOLD_TYPE_MONOPART ) {
309 return $e->die_event unless $e->allowed('TITLE_HOLDS', $porg);
310 } elsif ( $t eq OILS_HOLD_TYPE_ISSUANCE ) {
311 return $e->die_event unless $e->allowed('ISSUANCE_HOLDS', $porg);
312 } elsif ( $t eq OILS_HOLD_TYPE_COPY ) {
313 return $e->die_event unless $e->allowed('COPY_HOLDS', $porg);
314 } elsif ( $t eq OILS_HOLD_TYPE_FORCE || $t eq OILS_HOLD_TYPE_RECALL ) {
315 my $copy = $e->retrieve_asset_copy($hold->target)
316 or return $e->die_event;
317 if ( $t eq OILS_HOLD_TYPE_FORCE ) {
318 return $e->die_event unless $e->allowed('COPY_HOLDS_FORCE', $copy->circ_lib);
319 } elsif ( $t eq OILS_HOLD_TYPE_RECALL ) {
320 return $e->die_event unless $e->allowed('COPY_HOLDS_RECALL', $copy->circ_lib);
329 for my $evt (@events) {
331 my $name = $evt->{textcode};
332 if($oargs->{all} || grep { $_ eq $name } @{$oargs->{events}}) {
333 return $e->die_event unless $e->allowed("$name.override", $porg);
341 # Check for hold expiration in the past, and set it to empty string.
342 $hold->expire_time(undef) if ($hold->expire_time && $U->datecmp($hold->expire_time) == -1);
344 # set the configured expire time
345 unless($hold->expire_time) {
346 $hold->expire_time(calculate_expire_time($recipient->home_ou));
350 # if behind-the-desk pickup is supported at the hold pickup lib,
351 # set the value to the patron default, unless a value has already
352 # been applied. If it's not supported, force the value to false.
354 my $bdous = $U->ou_ancestor_setting_value(
356 'circ.holds.behind_desk_pickup_supported', $e);
359 if (!defined $hold->behind_desk) {
361 my $set = $e->search_actor_user_setting({
363 name => 'circ.holds_behind_desk'
366 $hold->behind_desk('t') if $set and
367 OpenSRF::Utils::JSON->JSON2perl($set->value);
370 # behind the desk not supported, force it to false
371 $hold->behind_desk('f');
374 $hold->requestor($e->requestor->id);
375 $hold->request_lib($e->requestor->ws_ou);
376 $hold->selection_ou($hold->pickup_lib) unless $hold->selection_ou;
377 $hold = $e->create_action_hold_request($hold) or return $e->die_event;
381 $conn->respond_complete($hold->id);
384 'open-ils.storage.action.hold_request.copy_targeter',
385 undef, $hold->id ) unless $U->is_true($hold->frozen);
390 # makes sure that a user has permission to place the type of requested hold
391 # returns the Perm exception if not allowed, returns undef if all is well
392 sub _check_holds_perm {
393 my($type, $user_id, $org_id) = @_;
397 $evt = $apputils->check_perms($user_id, $org_id, "MR_HOLDS" );
398 } elsif ($type eq "T") {
399 $evt = $apputils->check_perms($user_id, $org_id, "TITLE_HOLDS" );
400 } elsif($type eq "V") {
401 $evt = $apputils->check_perms($user_id, $org_id, "VOLUME_HOLDS");
402 } elsif($type eq "C") {
403 $evt = $apputils->check_perms($user_id, $org_id, "COPY_HOLDS" );
410 # tests if the given user is allowed to place holds on another's behalf
411 sub _check_request_holds_perm {
414 if (my $evt = $apputils->check_perms(
415 $user_id, $org_id, "REQUEST_HOLDS")) {
420 my $ses_is_req_note = 'The login session is the requestor. If the requestor is different from the user, ' .
421 'then the requestor must have VIEW_HOLD permissions';
423 __PACKAGE__->register_method(
424 method => "retrieve_holds_by_id",
425 api_name => "open-ils.circ.holds.retrieve_by_id",
427 desc => "Retrieve the hold, with hold transits attached, for the specified ID. $ses_is_req_note",
429 { desc => 'Authentication token', type => 'string' },
430 { desc => 'Hold ID', type => 'number' }
433 desc => 'Hold object with transits attached, event on error',
439 sub retrieve_holds_by_id {
440 my($self, $client, $auth, $hold_id) = @_;
441 my $e = new_editor(authtoken=>$auth);
442 $e->checkauth or return $e->event;
443 $e->allowed('VIEW_HOLD') or return $e->event;
445 my $holds = $e->search_action_hold_request(
447 { id => $hold_id , fulfillment_time => undef },
449 order_by => { ahr => "request_time" },
451 flesh_fields => {ahr => ['notes']}
456 flesh_hold_transits($holds);
457 flesh_hold_notices($holds, $e);
462 __PACKAGE__->register_method(
463 method => "retrieve_holds",
464 api_name => "open-ils.circ.holds.retrieve",
466 desc => "Retrieves all the holds, with hold transits attached, for the specified user. $ses_is_req_note",
468 { desc => 'Authentication token', type => 'string' },
469 { desc => 'User ID', type => 'integer' },
470 { desc => 'Available Only', type => 'boolean' }
473 desc => 'list of holds, event on error',
478 __PACKAGE__->register_method(
479 method => "retrieve_holds",
480 api_name => "open-ils.circ.holds.id_list.retrieve",
483 desc => "Retrieves all the hold IDs, for the specified user. $ses_is_req_note",
485 { desc => 'Authentication token', type => 'string' },
486 { desc => 'User ID', type => 'integer' },
487 { desc => 'Available Only', type => 'boolean' }
490 desc => 'list of holds, event on error',
495 __PACKAGE__->register_method(
496 method => "retrieve_holds",
497 api_name => "open-ils.circ.holds.canceled.retrieve",
500 desc => "Retrieves all the cancelled holds for the specified user. $ses_is_req_note",
502 { desc => 'Authentication token', type => 'string' },
503 { desc => 'User ID', type => 'integer' }
506 desc => 'list of holds, event on error',
511 __PACKAGE__->register_method(
512 method => "retrieve_holds",
513 api_name => "open-ils.circ.holds.canceled.id_list.retrieve",
516 desc => "Retrieves list of cancelled hold IDs for the specified user. $ses_is_req_note",
518 { desc => 'Authentication token', type => 'string' },
519 { desc => 'User ID', type => 'integer' }
522 desc => 'list of hold IDs, event on error',
529 my ($self, $client, $auth, $user_id, $available) = @_;
531 my $e = new_editor(authtoken=>$auth);
532 return $e->event unless $e->checkauth;
533 $user_id = $e->requestor->id unless defined $user_id;
535 my $notes_filter = {staff => 'f'};
536 my $user = $e->retrieve_actor_user($user_id) or return $e->event;
537 unless($user_id == $e->requestor->id) {
538 if($e->allowed('VIEW_HOLD', $user->home_ou)) {
539 $notes_filter = {staff => 't'}
541 my $allowed = OpenILS::Application::Actor::Friends->friend_perm_allowed(
542 $e, $user_id, $e->requestor->id, 'hold.view');
543 return $e->event unless $allowed;
546 # staff member looking at his/her own holds can see staff and non-staff notes
547 $notes_filter = {} if $e->allowed('VIEW_HOLD', $user->home_ou);
551 select => {ahr => ['id']},
553 where => {usr => $user_id, fulfillment_time => undef}
556 if($self->api_name =~ /canceled/) {
558 # Fetch the canceled holds
559 # order cancelled holds by cancel time, most recent first
561 $holds_query->{order_by} = [{class => 'ahr', field => 'cancel_time', direction => 'desc'}];
564 my $cancel_count = $U->ou_ancestor_setting_value(
565 $e->requestor->ws_ou, 'circ.holds.canceled.display_count', $e);
567 unless($cancel_count) {
568 $cancel_age = $U->ou_ancestor_setting_value(
569 $e->requestor->ws_ou, 'circ.holds.canceled.display_age', $e);
571 # if no settings are defined, default to last 10 cancelled holds
572 $cancel_count = 10 unless $cancel_age;
575 if($cancel_count) { # limit by count
577 $holds_query->{where}->{cancel_time} = {'!=' => undef};
578 $holds_query->{limit} = $cancel_count;
580 } elsif($cancel_age) { # limit by age
582 # find all of the canceled holds that were canceled within the configured time frame
583 my $date = DateTime->now->subtract(seconds => OpenSRF::Utils::interval_to_seconds($cancel_age));
584 $date = $U->epoch2ISO8601($date->epoch);
585 $holds_query->{where}->{cancel_time} = {'>=' => $date};
590 # order non-cancelled holds by ready-for-pickup, then active, followed by suspended
591 # "compare" sorts false values to the front. testing pickup_lib != current_shelf_lib
592 # will sort by pl = csl > pl != csl > followed by csl is null;
593 $holds_query->{order_by} = [
595 field => 'pickup_lib',
596 compare => {'!=' => {'+ahr' => 'current_shelf_lib'}}},
597 {class => 'ahr', field => 'shelf_time'},
598 {class => 'ahr', field => 'frozen'},
599 {class => 'ahr', field => 'request_time'}
602 $holds_query->{where}->{cancel_time} = undef;
604 $holds_query->{where}->{shelf_time} = {'!=' => undef};
606 $holds_query->{where}->{pickup_lib} = {'=' => {'+ahr' => 'current_shelf_lib'}};
610 my $hold_ids = $e->json_query($holds_query);
611 $hold_ids = [ map { $_->{id} } @$hold_ids ];
613 return $hold_ids if $self->api_name =~ /id_list/;
616 for my $hold_id ( @$hold_ids ) {
618 my $hold = $e->retrieve_action_hold_request($hold_id);
619 $hold->notes($e->search_action_hold_request_note({hold => $hold_id, %$notes_filter}));
622 $e->search_action_hold_transit_copy([
624 {order_by => {ahtc => 'source_send_time desc'}, limit => 1}])->[0]
634 __PACKAGE__->register_method(
635 method => 'user_hold_count',
636 api_name => 'open-ils.circ.hold.user.count'
639 sub user_hold_count {
640 my ( $self, $conn, $auth, $userid ) = @_;
641 my $e = new_editor( authtoken => $auth );
642 return $e->event unless $e->checkauth;
643 my $patron = $e->retrieve_actor_user($userid)
645 return $e->event unless $e->allowed( 'VIEW_HOLD', $patron->home_ou );
646 return __user_hold_count( $self, $e, $userid );
649 sub __user_hold_count {
650 my ( $self, $e, $userid ) = @_;
651 my $holds = $e->search_action_hold_request(
654 fulfillment_time => undef,
655 cancel_time => undef,
660 return scalar(@$holds);
664 __PACKAGE__->register_method(
665 method => "retrieve_holds_by_pickup_lib",
666 api_name => "open-ils.circ.holds.retrieve_by_pickup_lib",
668 "Retrieves all the holds, with hold transits attached, for the specified pickup_ou id."
671 __PACKAGE__->register_method(
672 method => "retrieve_holds_by_pickup_lib",
673 api_name => "open-ils.circ.holds.id_list.retrieve_by_pickup_lib",
674 notes => "Retrieves all the hold ids for the specified pickup_ou id. "
677 sub retrieve_holds_by_pickup_lib {
678 my ($self, $client, $login_session, $ou_id) = @_;
680 #FIXME -- put an appropriate permission check here
681 #my( $user, $target, $evt ) = $apputils->checkses_requestor(
682 # $login_session, $user_id, 'VIEW_HOLD' );
683 #return $evt if $evt;
685 my $holds = $apputils->simplereq(
687 "open-ils.cstore.direct.action.hold_request.search.atomic",
689 pickup_lib => $ou_id ,
690 fulfillment_time => undef,
693 { order_by => { ahr => "request_time" } }
696 if ( ! $self->api_name =~ /id_list/ ) {
697 flesh_hold_transits($holds);
701 return [ map { $_->id } @$holds ];
705 __PACKAGE__->register_method(
706 method => "uncancel_hold",
707 api_name => "open-ils.circ.hold.uncancel"
711 my($self, $client, $auth, $hold_id) = @_;
712 my $e = new_editor(authtoken=>$auth, xact=>1);
713 return $e->die_event unless $e->checkauth;
715 my $hold = $e->retrieve_action_hold_request($hold_id)
716 or return $e->die_event;
717 return $e->die_event unless $e->allowed('CANCEL_HOLDS', $hold->request_lib);
719 if ($hold->fulfillment_time) {
723 unless ($hold->cancel_time) {
728 # if configured to reset the request time, also reset the expire time
729 if($U->ou_ancestor_setting_value(
730 $hold->request_lib, 'circ.holds.uncancel.reset_request_time', $e)) {
732 $hold->request_time('now');
733 $hold->expire_time(calculate_expire_time($hold->request_lib));
736 $hold->clear_cancel_time;
737 $hold->clear_cancel_cause;
738 $hold->clear_cancel_note;
739 $hold->clear_shelf_time;
740 $hold->clear_current_copy;
741 $hold->clear_capture_time;
742 $hold->clear_prev_check_time;
743 $hold->clear_shelf_expire_time;
744 $hold->clear_current_shelf_lib;
746 $e->update_action_hold_request($hold) or return $e->die_event;
749 $U->storagereq('open-ils.storage.action.hold_request.copy_targeter', undef, $hold_id);
755 __PACKAGE__->register_method(
756 method => "cancel_hold",
757 api_name => "open-ils.circ.hold.cancel",
759 desc => 'Cancels the specified hold. The login session is the requestor. If the requestor is different from the usr field ' .
760 'on the hold, the requestor must have CANCEL_HOLDS permissions. The hold may be either the hold object or the hold id',
762 {desc => 'Authentication token', type => 'string'},
763 {desc => 'Hold ID', type => 'number'},
764 {desc => 'Cause of Cancellation', type => 'string'},
765 {desc => 'Note', type => 'string'}
768 desc => '1 on success, event on error'
774 my($self, $client, $auth, $holdid, $cause, $note) = @_;
776 my $e = new_editor(authtoken=>$auth, xact=>1);
777 return $e->die_event unless $e->checkauth;
779 my $hold = $e->retrieve_action_hold_request($holdid)
780 or return $e->die_event;
782 if( $e->requestor->id ne $hold->usr ) {
783 return $e->die_event unless $e->allowed('CANCEL_HOLDS');
786 if ($hold->cancel_time) {
791 # If the hold is captured, reset the copy status
792 if( $hold->capture_time and $hold->current_copy ) {
794 my $copy = $e->retrieve_asset_copy($hold->current_copy)
795 or return $e->die_event;
797 if( $copy->status == OILS_COPY_STATUS_ON_HOLDS_SHELF ) {
798 $logger->info("canceling hold $holdid whose item is on the holds shelf");
799 # $logger->info("setting copy to status 'reshelving' on hold cancel");
800 # $copy->status(OILS_COPY_STATUS_RESHELVING);
801 # $copy->editor($e->requestor->id);
802 # $copy->edit_date('now');
803 # $e->update_asset_copy($copy) or return $e->event;
805 } elsif( $copy->status == OILS_COPY_STATUS_IN_TRANSIT ) {
808 $logger->warn("! canceling hold [$hid] that is in transit");
809 my $transid = $e->search_action_hold_transit_copy({hold=>$hold->id},{idlist=>1})->[0];
812 my $trans = $e->retrieve_action_transit_copy($transid);
813 # Leave the transit alive, but set the copy status to
814 # reshelving so it will be properly reshelved when it gets back home
816 $trans->copy_status( OILS_COPY_STATUS_RESHELVING );
817 $e->update_action_transit_copy($trans) or return $e->die_event;
823 $hold->cancel_time('now');
824 $hold->cancel_cause($cause);
825 $hold->cancel_note($note);
826 $e->update_action_hold_request($hold)
827 or return $e->die_event;
829 delete_hold_copy_maps($self, $e, $hold->id);
833 # re-fetch the hold to pick up the real cancel_time (not "now") for A/T
835 $hold = $e->retrieve_action_hold_request($hold->id) or return $e->die_event;
838 if ($e->requestor->id == $hold->usr) {
839 $U->create_events_for_hook('hold_request.cancel.patron', $hold, $hold->pickup_lib);
841 $U->create_events_for_hook('hold_request.cancel.staff', $hold, $hold->pickup_lib);
847 sub delete_hold_copy_maps {
852 my $maps = $editor->search_action_hold_copy_map({hold=>$holdid});
854 $editor->delete_action_hold_copy_map($_)
855 or return $editor->event;
861 my $update_hold_desc = 'The login session is the requestor. ' .
862 'If the requestor is different from the usr field on the hold, ' .
863 'the requestor must have UPDATE_HOLDS permissions. ' .
864 'If supplying a hash of hold data, "id" must be included. ' .
865 'The hash is ignored if a hold object is supplied, ' .
866 'so you should supply only one kind of hold data argument.' ;
868 __PACKAGE__->register_method(
869 method => "update_hold",
870 api_name => "open-ils.circ.hold.update",
872 desc => "Updates the specified hold. $update_hold_desc",
874 {desc => 'Authentication token', type => 'string'},
875 {desc => 'Hold Object', type => 'object'},
876 {desc => 'Hash of values to be applied', type => 'object'}
879 desc => 'Hold ID on success, event on error',
885 __PACKAGE__->register_method(
886 method => "batch_update_hold",
887 api_name => "open-ils.circ.hold.update.batch",
890 desc => "Updates the specified hold(s). $update_hold_desc",
892 {desc => 'Authentication token', type => 'string'},
893 {desc => 'Array of hold obejcts', type => 'array' },
894 {desc => 'Array of hashes of values to be applied', type => 'array' }
897 desc => 'Hold ID per success, event per error',
903 my($self, $client, $auth, $hold, $values) = @_;
904 my $e = new_editor(authtoken=>$auth, xact=>1);
905 return $e->die_event unless $e->checkauth;
906 my $resp = update_hold_impl($self, $e, $hold, $values);
907 if ($U->event_code($resp)) {
911 $e->commit; # FIXME: update_hold_impl already does $e->commit ??
915 sub batch_update_hold {
916 my($self, $client, $auth, $hold_list, $values_list) = @_;
917 my $e = new_editor(authtoken=>$auth);
918 return $e->die_event unless $e->checkauth;
920 my $count = ($hold_list) ? scalar(@$hold_list) : scalar(@$values_list); # FIXME: we don't know for sure that we got $values_list. we could have neither list.
922 $values_list ||= []; # FIXME: either move this above $count declaration, or send an event if both lists undef. Probably the latter.
924 # FIXME: Failing over to [] guarantees warnings for "Use of unitialized value" in update_hold_impl call.
925 # FIXME: We should be sure we only call update_hold_impl with hold object OR hash, not both.
927 for my $idx (0..$count-1) {
929 my $resp = update_hold_impl($self, $e, $hold_list->[$idx], $values_list->[$idx]);
930 $e->xact_commit unless $U->event_code($resp);
931 $client->respond($resp);
935 return undef; # not in the register return type, assuming we should always have at least one list populated
938 sub update_hold_impl {
939 my($self, $e, $hold, $values) = @_;
941 my $need_retarget = 0;
944 $hold = $e->retrieve_action_hold_request($values->{id})
945 or return $e->die_event;
946 for my $k (keys %$values) {
947 # Outside of pickup_lib (covered by the first regex) I don't know when these would currently change.
948 # But hey, why not cover things that may happen later?
949 if ($k =~ '_(lib|ou)$' || $k eq 'target' || $k eq 'hold_type' || $k eq 'requestor' || $k eq 'selection_depth' || $k eq 'holdable_formats') {
950 if (defined $values->{$k} && defined $hold->$k() && $values->{$k} ne $hold->$k()) {
951 # Value changed? RETARGET!
953 } elsif (defined $hold->$k() != defined $values->{$k}) {
954 # Value being set or cleared? RETARGET!
958 if (defined $values->{$k}) {
959 $hold->$k($values->{$k});
961 my $f = "clear_$k"; $hold->$f();
966 my $orig_hold = $e->retrieve_action_hold_request($hold->id)
967 or return $e->die_event;
969 # don't allow the user to be changed
970 return OpenILS::Event->new('BAD_PARAMS') if $hold->usr != $orig_hold->usr;
972 if($hold->usr ne $e->requestor->id) {
973 # if the hold is for a different user, make sure the
974 # requestor has the appropriate permissions
975 my $usr = $e->retrieve_actor_user($hold->usr)
976 or return $e->die_event;
977 return $e->die_event unless $e->allowed('UPDATE_HOLD', $usr->home_ou);
981 # --------------------------------------------------------------
982 # Changing the request time is like playing God
983 # --------------------------------------------------------------
984 if($hold->request_time ne $orig_hold->request_time) {
985 return OpenILS::Event->new('BAD_PARAMS') if $hold->fulfillment_time;
986 return $e->die_event unless $e->allowed('UPDATE_HOLD_REQUEST_TIME', $hold->pickup_lib);
990 # --------------------------------------------------------------
991 # Code for making sure staff have appropriate permissons for cut_in_line
992 # This, as is, doesn't prevent a user from cutting their own holds in line
994 # --------------------------------------------------------------
995 if($U->is_true($hold->cut_in_line) ne $U->is_true($orig_hold->cut_in_line)) {
996 return $e->die_event unless $e->allowed('UPDATE_HOLD_REQUEST_TIME', $hold->pickup_lib);
1000 # --------------------------------------------------------------
1001 # Disallow hold suspencion if the hold is already captured.
1002 # --------------------------------------------------------------
1003 if ($U->is_true($hold->frozen) and not $U->is_true($orig_hold->frozen)) {
1004 $hold_status = _hold_status($e, $hold);
1005 if ($hold_status > 2 && $hold_status != 7) { # hold is captured
1006 $logger->info("bypassing hold freeze on captured hold");
1007 return OpenILS::Event->new('HOLD_SUSPEND_AFTER_CAPTURE');
1012 # --------------------------------------------------------------
1013 # if the hold is on the holds shelf or in transit and the pickup
1014 # lib changes we need to create a new transit.
1015 # --------------------------------------------------------------
1016 if($orig_hold->pickup_lib ne $hold->pickup_lib) {
1018 $hold_status = _hold_status($e, $hold) unless $hold_status;
1020 if($hold_status == 3) { # in transit
1022 return $e->die_event unless $e->allowed('UPDATE_PICKUP_LIB_FROM_TRANSIT', $orig_hold->pickup_lib);
1023 return $e->die_event unless $e->allowed('UPDATE_PICKUP_LIB_FROM_TRANSIT', $hold->pickup_lib);
1025 $logger->info("updating pickup lib for hold ".$hold->id." while already in transit");
1027 # update the transit to reflect the new pickup location
1028 my $transit = $e->search_action_hold_transit_copy(
1029 {hold=>$hold->id, dest_recv_time => undef})->[0]
1030 or return $e->die_event;
1032 $transit->prev_dest($transit->dest); # mark the previous destination on the transit
1033 $transit->dest($hold->pickup_lib);
1034 $e->update_action_hold_transit_copy($transit) or return $e->die_event;
1036 } elsif($hold_status == 4 or $hold_status == 5 or $hold_status == 8) { # on holds shelf
1038 return $e->die_event unless $e->allowed('UPDATE_PICKUP_LIB_FROM_HOLDS_SHELF', $orig_hold->pickup_lib);
1039 return $e->die_event unless $e->allowed('UPDATE_PICKUP_LIB_FROM_HOLDS_SHELF', $hold->pickup_lib);
1041 $logger->info("updating pickup lib for hold ".$hold->id." while on holds shelf");
1043 if ($hold->pickup_lib eq $orig_hold->current_shelf_lib) {
1044 # This can happen if the pickup lib is changed while the hold is
1045 # on the shelf, then changed back to the original pickup lib.
1046 # Restore the original shelf_expire_time to prevent abuse.
1047 set_hold_shelf_expire_time(undef, $hold, $e, $hold->shelf_time);
1050 # clear to prevent premature shelf expiration
1051 $hold->clear_shelf_expire_time;
1056 if($U->is_true($hold->frozen)) {
1057 $logger->info("clearing current_copy and check_time for frozen hold ".$hold->id);
1058 $hold->clear_current_copy;
1059 $hold->clear_prev_check_time;
1060 # Clear expire_time to prevent frozen holds from expiring.
1061 $logger->info("clearing expire_time for frozen hold ".$hold->id);
1062 $hold->clear_expire_time;
1065 # If the hold_expire_time is in the past && is not equal to the
1066 # original expire_time, then reset the expire time to be in the
1068 if ($hold->expire_time && $U->datecmp($hold->expire_time) == -1 && $U->datecmp($hold->expire_time, $orig_hold->expire_time) != 0) {
1069 $hold->expire_time(calculate_expire_time($hold->request_lib));
1072 # If the hold is reactivated, reset the expire_time.
1073 if(!$U->is_true($hold->frozen) && $U->is_true($orig_hold->frozen)) {
1074 $logger->info("Reset expire_time on activated hold ".$hold->id);
1075 $hold->expire_time(calculate_expire_time($hold->request_lib));
1078 $e->update_action_hold_request($hold) or return $e->die_event;
1081 if(!$U->is_true($hold->frozen) && $U->is_true($orig_hold->frozen)) {
1082 $logger->info("Running targeter on activated hold ".$hold->id);
1083 $U->storagereq( 'open-ils.storage.action.hold_request.copy_targeter', undef, $hold->id );
1086 # a change to mint-condition changes the set of potential copies, so retarget the hold;
1087 if($U->is_true($hold->mint_condition) and !$U->is_true($orig_hold->mint_condition)) {
1088 _reset_hold($self, $e->requestor, $hold)
1089 } elsif($need_retarget && !defined $hold->capture_time()) { # If needed, retarget the hold due to changes
1091 'open-ils.storage.action.hold_request.copy_targeter', undef, $hold->id );
1097 # this does not update the hold in the DB. It only
1098 # sets the shelf_expire_time field on the hold object.
1099 # start_time is optional and defaults to 'now'
1100 sub set_hold_shelf_expire_time {
1101 my ($class, $hold, $editor, $start_time) = @_;
1103 my $shelf_expire = $U->ou_ancestor_setting_value(
1105 'circ.holds.default_shelf_expire_interval',
1109 return undef unless $shelf_expire;
1111 $start_time = ($start_time) ?
1112 DateTime::Format::ISO8601->new->parse_datetime(cleanse_ISO8601($start_time)) :
1113 DateTime->now(time_zone => 'local'); # without time_zone we get UTC ... yuck!
1115 my $seconds = OpenSRF::Utils->interval_to_seconds($shelf_expire);
1116 my $expire_time = $start_time->add(seconds => $seconds);
1118 # if the shelf expire time overlaps with a pickup lib's
1119 # closed date, push it out to the first open date
1120 my $dateinfo = $U->storagereq(
1121 'open-ils.storage.actor.org_unit.closed_date.overlap',
1122 $hold->pickup_lib, $expire_time->strftime('%FT%T%z'));
1125 my $dt_parser = DateTime::Format::ISO8601->new;
1126 $expire_time = $dt_parser->parse_datetime(cleanse_ISO8601($dateinfo->{end}));
1128 # TODO: enable/disable time bump via setting?
1129 $expire_time->set(hour => '23', minute => '59', second => '59');
1131 $logger->info("circulator: shelf_expire_time overlaps".
1132 " with closed date, pushing expire time to $expire_time");
1135 $hold->shelf_expire_time($expire_time->strftime('%FT%T%z'));
1141 my($e, $orig_hold, $hold, $copy) = @_;
1142 my $src = $orig_hold->pickup_lib;
1143 my $dest = $hold->pickup_lib;
1145 $logger->info("putting hold into transit on pickup_lib update");
1147 my $transit = Fieldmapper::action::hold_transit_copy->new;
1148 $transit->hold($hold->id);
1149 $transit->source($src);
1150 $transit->dest($dest);
1151 $transit->target_copy($copy->id);
1152 $transit->source_send_time('now');
1153 $transit->copy_status(OILS_COPY_STATUS_ON_HOLDS_SHELF);
1155 $copy->status(OILS_COPY_STATUS_IN_TRANSIT);
1156 $copy->editor($e->requestor->id);
1157 $copy->edit_date('now');
1159 $e->create_action_hold_transit_copy($transit) or return $e->die_event;
1160 $e->update_asset_copy($copy) or return $e->die_event;
1164 # if the hold is frozen, this method ensures that the hold is not "targeted",
1165 # that is, it clears the current_copy and prev_check_time to essentiallly
1166 # reset the hold. If it is being activated, it runs the targeter in the background
1167 sub update_hold_if_frozen {
1168 my($self, $e, $hold, $orig_hold) = @_;
1169 return if $hold->capture_time;
1171 if($U->is_true($hold->frozen)) {
1172 $logger->info("clearing current_copy and check_time for frozen hold ".$hold->id);
1173 $hold->clear_current_copy;
1174 $hold->clear_prev_check_time;
1177 if($U->is_true($orig_hold->frozen)) {
1178 $logger->info("Running targeter on activated hold ".$hold->id);
1179 $U->storagereq( 'open-ils.storage.action.hold_request.copy_targeter', undef, $hold->id );
1184 __PACKAGE__->register_method(
1185 method => "hold_note_CUD",
1186 api_name => "open-ils.circ.hold_request.note.cud",
1188 desc => 'Create, update or delete a hold request note. If the operator (from Auth. token) '
1189 . 'is not the owner of the hold, the UPDATE_HOLD permission is required',
1191 { desc => 'Authentication token', type => 'string' },
1192 { desc => 'Hold note object', type => 'object' }
1195 desc => 'Returns the note ID, event on error'
1201 my($self, $conn, $auth, $note) = @_;
1203 my $e = new_editor(authtoken => $auth, xact => 1);
1204 return $e->die_event unless $e->checkauth;
1206 my $hold = $e->retrieve_action_hold_request($note->hold)
1207 or return $e->die_event;
1209 if($hold->usr ne $e->requestor->id) {
1210 my $usr = $e->retrieve_actor_user($hold->usr);
1211 return $e->die_event unless $e->allowed('UPDATE_HOLD', $usr->home_ou);
1212 $note->staff('t') if $note->isnew;
1216 $e->create_action_hold_request_note($note) or return $e->die_event;
1217 } elsif($note->ischanged) {
1218 $e->update_action_hold_request_note($note) or return $e->die_event;
1219 } elsif($note->isdeleted) {
1220 $e->delete_action_hold_request_note($note) or return $e->die_event;
1228 __PACKAGE__->register_method(
1229 method => "retrieve_hold_status",
1230 api_name => "open-ils.circ.hold.status.retrieve",
1232 desc => 'Calculates the current status of the hold. The requestor must have ' .
1233 'VIEW_HOLD permissions if the hold is for a user other than the requestor' ,
1235 { desc => 'Hold ID', type => 'number' }
1238 # type => 'number', # event sometimes
1239 desc => <<'END_OF_DESC'
1240 Returns event on error or:
1241 -1 on error (for now),
1242 1 for 'waiting for copy to become available',
1243 2 for 'waiting for copy capture',
1246 5 for 'hold-shelf-delay'
1249 8 for 'captured, on wrong hold shelf'
1255 sub retrieve_hold_status {
1256 my($self, $client, $auth, $hold_id) = @_;
1258 my $e = new_editor(authtoken => $auth);
1259 return $e->event unless $e->checkauth;
1260 my $hold = $e->retrieve_action_hold_request($hold_id)
1261 or return $e->event;
1263 if( $e->requestor->id != $hold->usr ) {
1264 return $e->event unless $e->allowed('VIEW_HOLD');
1267 return _hold_status($e, $hold);
1273 if ($hold->cancel_time) {
1276 if ($U->is_true($hold->frozen) && !$hold->capture_time) {
1279 if ($hold->current_shelf_lib and $hold->current_shelf_lib ne $hold->pickup_lib) {
1282 return 1 unless $hold->current_copy;
1283 return 2 unless $hold->capture_time;
1285 my $copy = $hold->current_copy;
1286 unless( ref $copy ) {
1287 $copy = $e->retrieve_asset_copy($hold->current_copy)
1288 or return $e->event;
1291 return 3 if $copy->status == OILS_COPY_STATUS_IN_TRANSIT;
1293 if($copy->status == OILS_COPY_STATUS_ON_HOLDS_SHELF) {
1295 my $hs_wait_interval = $U->ou_ancestor_setting_value($hold->pickup_lib, 'circ.hold_shelf_status_delay');
1296 return 4 unless $hs_wait_interval;
1298 # if a hold_shelf_status_delay interval is defined and start_time plus
1299 # the interval is greater than now, consider the hold to be in the virtual
1300 # "on its way to the holds shelf" status. Return 5.
1302 my $transit = $e->search_action_hold_transit_copy({hold => $hold->id})->[0];
1303 my $start_time = ($transit) ? $transit->dest_recv_time : $hold->capture_time;
1304 $start_time = DateTime::Format::ISO8601->new->parse_datetime(cleanse_ISO8601($start_time));
1305 my $end_time = $start_time->add(seconds => OpenSRF::Utils::interval_to_seconds($hs_wait_interval));
1307 return 5 if $end_time > DateTime->now;
1316 __PACKAGE__->register_method(
1317 method => "retrieve_hold_queue_stats",
1318 api_name => "open-ils.circ.hold.queue_stats.retrieve",
1320 desc => 'Returns summary data about the state of a hold',
1322 { desc => 'Authentication token', type => 'string'},
1323 { desc => 'Hold ID', type => 'number'},
1326 desc => q/Summary object with keys:
1327 total_holds : total holds in queue
1328 queue_position : current queue position
1329 potential_copies : number of potential copies for this hold
1330 estimated_wait : estimated wait time in days
1331 status : hold status
1332 -1 => error or unexpected state,
1333 1 => 'waiting for copy to become available',
1334 2 => 'waiting for copy capture',
1337 5 => 'hold-shelf-delay'
1344 sub retrieve_hold_queue_stats {
1345 my($self, $conn, $auth, $hold_id) = @_;
1346 my $e = new_editor(authtoken => $auth);
1347 return $e->event unless $e->checkauth;
1348 my $hold = $e->retrieve_action_hold_request($hold_id) or return $e->event;
1349 if($e->requestor->id != $hold->usr) {
1350 return $e->event unless $e->allowed('VIEW_HOLD');
1352 return retrieve_hold_queue_status_impl($e, $hold);
1355 sub retrieve_hold_queue_status_impl {
1359 # The holds queue is defined as the distinct set of holds that share at
1360 # least one potential copy with the context hold, plus any holds that
1361 # share the same hold type and target. The latter part exists to
1362 # accomodate holds that currently have no potential copies
1363 my $q_holds = $e->json_query({
1365 # fetch cut_in_line and request_time since they're in the order_by
1366 # and we're asking for distinct values
1367 select => {ahr => ['id', 'cut_in_line', 'request_time']},
1371 select => { ahcm => ['hold'] },
1376 'field' => 'target_copy',
1377 'fkey' => 'target_copy'
1381 where => { '+ahcm2' => { hold => $hold->id } },
1388 "field" => "cut_in_line",
1389 "transform" => "coalesce",
1391 "direction" => "desc"
1393 { "class" => "ahr", "field" => "request_time" }
1398 if (!@$q_holds) { # none? maybe we don't have a map ...
1399 $q_holds = $e->json_query({
1400 select => {ahr => ['id', 'cut_in_line', 'request_time']},
1405 "field" => "cut_in_line",
1406 "transform" => "coalesce",
1408 "direction" => "desc"
1410 { "class" => "ahr", "field" => "request_time" }
1413 hold_type => $hold->hold_type,
1414 target => $hold->target,
1415 capture_time => undef,
1416 cancel_time => undef,
1418 {expire_time => undef },
1419 {expire_time => {'>' => 'now'}}
1427 for my $h (@$q_holds) {
1428 last if $h->{id} == $hold->id;
1432 my $hold_data = $e->json_query({
1434 acp => [ {column => 'id', transform => 'count', aggregate => 1, alias => 'count'} ],
1435 ccm => [ {column =>'avg_wait_time'} ]
1441 ccm => {type => 'left'}
1446 where => {'+ahcm' => {hold => $hold->id} }
1449 my $user_org = $e->json_query({select => {au => ['home_ou']}, from => 'au', where => {id => $hold->usr}})->[0]->{home_ou};
1451 my $default_wait = $U->ou_ancestor_setting_value($user_org, OILS_SETTING_HOLD_ESIMATE_WAIT_INTERVAL);
1452 my $min_wait = $U->ou_ancestor_setting_value($user_org, 'circ.holds.min_estimated_wait_interval');
1453 $min_wait = OpenSRF::Utils::interval_to_seconds($min_wait || '0 seconds');
1454 $default_wait ||= '0 seconds';
1456 # Estimated wait time is the average wait time across the set
1457 # of potential copies, divided by the number of potential copies
1458 # times the queue position.
1460 my $combined_secs = 0;
1461 my $num_potentials = 0;
1463 for my $wait_data (@$hold_data) {
1464 my $count += $wait_data->{count};
1465 $combined_secs += $count *
1466 OpenSRF::Utils::interval_to_seconds($wait_data->{avg_wait_time} || $default_wait);
1467 $num_potentials += $count;
1470 my $estimated_wait = -1;
1472 if($num_potentials) {
1473 my $avg_wait = $combined_secs / $num_potentials;
1474 $estimated_wait = $qpos * ($avg_wait / $num_potentials);
1475 $estimated_wait = $min_wait if $estimated_wait < $min_wait and $estimated_wait != -1;
1479 total_holds => scalar(@$q_holds),
1480 queue_position => $qpos,
1481 potential_copies => $num_potentials,
1482 status => _hold_status( $e, $hold ),
1483 estimated_wait => int($estimated_wait)
1488 sub fetch_open_hold_by_current_copy {
1491 my $hold = $apputils->simplereq(
1493 'open-ils.cstore.direct.action.hold_request.search.atomic',
1494 { current_copy => $copyid , cancel_time => undef, fulfillment_time => undef });
1495 return $hold->[0] if ref($hold);
1499 sub fetch_related_holds {
1502 return $apputils->simplereq(
1504 'open-ils.cstore.direct.action.hold_request.search.atomic',
1505 { current_copy => $copyid , cancel_time => undef, fulfillment_time => undef });
1509 __PACKAGE__->register_method(
1510 method => "hold_pull_list",
1511 api_name => "open-ils.circ.hold_pull_list.retrieve",
1513 desc => 'Returns (reference to) a list of holds that need to be "pulled" by a given location. ' .
1514 'The location is determined by the login session.',
1516 { desc => 'Limit (optional)', type => 'number'},
1517 { desc => 'Offset (optional)', type => 'number'},
1520 desc => 'reference to a list of holds, or event on failure',
1525 __PACKAGE__->register_method(
1526 method => "hold_pull_list",
1527 api_name => "open-ils.circ.hold_pull_list.id_list.retrieve",
1529 desc => 'Returns (reference to) a list of holds IDs that need to be "pulled" by a given location. ' .
1530 'The location is determined by the login session.',
1532 { desc => 'Limit (optional)', type => 'number'},
1533 { desc => 'Offset (optional)', type => 'number'},
1536 desc => 'reference to a list of holds, or event on failure',
1541 __PACKAGE__->register_method(
1542 method => "hold_pull_list",
1543 api_name => "open-ils.circ.hold_pull_list.retrieve.count",
1545 desc => 'Returns a count of holds that need to be "pulled" by a given location. ' .
1546 'The location is determined by the login session.',
1548 { desc => 'Limit (optional)', type => 'number'},
1549 { desc => 'Offset (optional)', type => 'number'},
1552 desc => 'Holds count (integer), or event on failure',
1559 sub hold_pull_list {
1560 my( $self, $conn, $authtoken, $limit, $offset ) = @_;
1561 my( $reqr, $evt ) = $U->checkses($authtoken);
1562 return $evt if $evt;
1564 my $org = $reqr->ws_ou || $reqr->home_ou;
1565 # the perm locaiton shouldn't really matter here since holds
1566 # will exist all over and VIEW_HOLDS should be universal
1567 $evt = $U->check_perms($reqr->id, $org, 'VIEW_HOLD');
1568 return $evt if $evt;
1570 if($self->api_name =~ /count/) {
1572 my $count = $U->storagereq(
1573 'open-ils.storage.direct.action.hold_request.pull_list.current_copy_circ_lib.status_filtered.count',
1574 $org, $limit, $offset );
1576 $logger->info("Grabbing pull list for org unit $org with $count items");
1579 } elsif( $self->api_name =~ /id_list/ ) {
1580 return $U->storagereq(
1581 'open-ils.storage.direct.action.hold_request.pull_list.id_list.current_copy_circ_lib.status_filtered.atomic',
1582 $org, $limit, $offset );
1585 return $U->storagereq(
1586 'open-ils.storage.direct.action.hold_request.pull_list.search.current_copy_circ_lib.status_filtered.atomic',
1587 $org, $limit, $offset );
1591 __PACKAGE__->register_method(
1592 method => "print_hold_pull_list",
1593 api_name => "open-ils.circ.hold_pull_list.print",
1595 desc => 'Returns an HTML-formatted holds pull list',
1597 { desc => 'Authtoken', type => 'string'},
1598 { desc => 'Org unit ID. Optional, defaults to workstation org unit', type => 'number'},
1601 desc => 'HTML string',
1607 sub print_hold_pull_list {
1608 my($self, $client, $auth, $org_id) = @_;
1610 my $e = new_editor(authtoken=>$auth);
1611 return $e->event unless $e->checkauth;
1613 $org_id = (defined $org_id) ? $org_id : $e->requestor->ws_ou;
1614 return $e->event unless $e->allowed('VIEW_HOLD', $org_id);
1616 my $hold_ids = $U->storagereq(
1617 'open-ils.storage.direct.action.hold_request.pull_list.id_list.current_copy_circ_lib.status_filtered.atomic',
1620 return undef unless @$hold_ids;
1622 $client->status(new OpenSRF::DomainObject::oilsContinueStatus);
1624 # Holds will /NOT/ be in order after this ...
1625 my $holds = $e->search_action_hold_request({id => $hold_ids}, {substream => 1});
1626 $client->status(new OpenSRF::DomainObject::oilsContinueStatus);
1628 # ... so we must resort.
1629 my $hold_map = +{map { $_->id => $_ } @$holds};
1630 my $sorted_holds = [];
1631 push @$sorted_holds, $hold_map->{$_} foreach @$hold_ids;
1633 return $U->fire_object_event(
1634 undef, "ahr.format.pull_list", $sorted_holds,
1635 $org_id, undef, undef, $client
1640 __PACKAGE__->register_method(
1641 method => "print_hold_pull_list_stream",
1643 api_name => "open-ils.circ.hold_pull_list.print.stream",
1645 desc => 'Returns a stream of fleshed holds',
1647 { desc => 'Authtoken', type => 'string'},
1648 { desc => 'Hash of optional param: Org unit ID (defaults to workstation org unit), limit, offset, sort (array of: acplo.position, prefix, call_number, suffix, request_time)',
1653 desc => 'A stream of fleshed holds',
1659 sub print_hold_pull_list_stream {
1660 my($self, $client, $auth, $params) = @_;
1662 my $e = new_editor(authtoken=>$auth);
1663 return $e->die_event unless $e->checkauth;
1665 delete($$params{org_id}) unless (int($$params{org_id}));
1666 delete($$params{limit}) unless (int($$params{limit}));
1667 delete($$params{offset}) unless (int($$params{offset}));
1668 delete($$params{chunk_size}) unless (int($$params{chunk_size}));
1669 delete($$params{chunk_size}) if ($$params{chunk_size} && $$params{chunk_size} > 50); # keep the size reasonable
1670 $$params{chunk_size} ||= 10;
1672 $$params{org_id} = (defined $$params{org_id}) ? $$params{org_id}: $e->requestor->ws_ou;
1673 return $e->die_event unless $e->allowed('VIEW_HOLD', $$params{org_id });
1676 if ($$params{sort} && @{ $$params{sort} }) {
1677 for my $s (@{ $$params{sort} }) {
1678 if ($s eq 'acplo.position') {
1680 "class" => "acplo", "field" => "position",
1681 "transform" => "coalesce", "params" => [999]
1683 } elsif ($s eq 'prefix') {
1684 push @$sort, {"class" => "acnp", "field" => "label_sortkey"};
1685 } elsif ($s eq 'call_number') {
1686 push @$sort, {"class" => "acn", "field" => "label_sortkey"};
1687 } elsif ($s eq 'suffix') {
1688 push @$sort, {"class" => "acns", "field" => "label_sortkey"};
1689 } elsif ($s eq 'request_time') {
1690 push @$sort, {"class" => "ahr", "field" => "request_time"};
1694 push @$sort, {"class" => "ahr", "field" => "request_time"};
1697 my $holds_ids = $e->json_query(
1699 "select" => {"ahr" => ["id"]},
1704 "fkey" => "current_copy",
1706 "circ_lib" => $$params{org_id}, "status" => [0,7]
1711 "fkey" => "call_number",
1725 "fkey" => "circ_lib",
1728 "location" => {"=" => {"+acp" => "location"}}
1737 "capture_time" => undef,
1738 "cancel_time" => undef,
1740 {"expire_time" => undef },
1741 {"expire_time" => {">" => "now"}}
1745 (@$sort ? (order_by => $sort) : ()),
1746 ($$params{limit} ? (limit => $$params{limit}) : ()),
1747 ($$params{offset} ? (offset => $$params{offset}) : ())
1748 }, {"substream" => 1}
1749 ) or return $e->die_event;
1751 $logger->info("about to stream back " . scalar(@$holds_ids) . " holds");
1754 for my $hid (@$holds_ids) {
1755 push @chunk, $e->retrieve_action_hold_request([
1759 "ahr" => ["usr", "current_copy"],
1761 "acp" => ["location", "call_number", "parts"],
1762 "acn" => ["record","prefix","suffix"]
1767 if (@chunk >= $$params{chunk_size}) {
1768 $client->respond( \@chunk );
1772 $client->respond_complete( \@chunk ) if (@chunk);
1779 __PACKAGE__->register_method(
1780 method => 'fetch_hold_notify',
1781 api_name => 'open-ils.circ.hold_notification.retrieve_by_hold',
1784 Returns a list of hold notification objects based on hold id.
1785 @param authtoken The loggin session key
1786 @param holdid The id of the hold whose notifications we want to retrieve
1787 @return An array of hold notification objects, event on error.
1791 sub fetch_hold_notify {
1792 my( $self, $conn, $authtoken, $holdid ) = @_;
1793 my( $requestor, $evt ) = $U->checkses($authtoken);
1794 return $evt if $evt;
1795 my ($hold, $patron);
1796 ($hold, $evt) = $U->fetch_hold($holdid);
1797 return $evt if $evt;
1798 ($patron, $evt) = $U->fetch_user($hold->usr);
1799 return $evt if $evt;
1801 $evt = $U->check_perms($requestor->id, $patron->home_ou, 'VIEW_HOLD_NOTIFICATION');
1802 return $evt if $evt;
1804 $logger->info("User ".$requestor->id." fetching hold notifications for hold $holdid");
1805 return $U->cstorereq(
1806 'open-ils.cstore.direct.action.hold_notification.search.atomic', {hold => $holdid} );
1810 __PACKAGE__->register_method(
1811 method => 'create_hold_notify',
1812 api_name => 'open-ils.circ.hold_notification.create',
1814 Creates a new hold notification object
1815 @param authtoken The login session key
1816 @param notification The hold notification object to create
1817 @return ID of the new object on success, Event on error
1821 sub create_hold_notify {
1822 my( $self, $conn, $auth, $note ) = @_;
1823 my $e = new_editor(authtoken=>$auth, xact=>1);
1824 return $e->die_event unless $e->checkauth;
1826 my $hold = $e->retrieve_action_hold_request($note->hold)
1827 or return $e->die_event;
1828 my $patron = $e->retrieve_actor_user($hold->usr)
1829 or return $e->die_event;
1831 return $e->die_event unless
1832 $e->allowed('CREATE_HOLD_NOTIFICATION', $patron->home_ou);
1834 $note->notify_staff($e->requestor->id);
1835 $e->create_action_hold_notification($note) or return $e->die_event;
1840 __PACKAGE__->register_method(
1841 method => 'create_hold_note',
1842 api_name => 'open-ils.circ.hold_note.create',
1844 Creates a new hold request note object
1845 @param authtoken The login session key
1846 @param note The hold note object to create
1847 @return ID of the new object on success, Event on error
1851 sub create_hold_note {
1852 my( $self, $conn, $auth, $note ) = @_;
1853 my $e = new_editor(authtoken=>$auth, xact=>1);
1854 return $e->die_event unless $e->checkauth;
1856 my $hold = $e->retrieve_action_hold_request($note->hold)
1857 or return $e->die_event;
1858 my $patron = $e->retrieve_actor_user($hold->usr)
1859 or return $e->die_event;
1861 return $e->die_event unless
1862 $e->allowed('UPDATE_HOLD', $patron->home_ou); # FIXME: Using permcrud perm listed in fm_IDL.xml for ahrn. Probably want something more specific
1864 $e->create_action_hold_request_note($note) or return $e->die_event;
1869 __PACKAGE__->register_method(
1870 method => 'reset_hold',
1871 api_name => 'open-ils.circ.hold.reset',
1873 Un-captures and un-targets a hold, essentially returning
1874 it to the state it was in directly after it was placed,
1875 then attempts to re-target the hold
1876 @param authtoken The login session key
1877 @param holdid The id of the hold
1883 my( $self, $conn, $auth, $holdid ) = @_;
1885 my ($hold, $evt) = $U->fetch_hold($holdid);
1886 return $evt if $evt;
1887 ($reqr, $evt) = $U->checksesperm($auth, 'UPDATE_HOLD');
1888 return $evt if $evt;
1889 $evt = _reset_hold($self, $reqr, $hold);
1890 return $evt if $evt;
1895 __PACKAGE__->register_method(
1896 method => 'reset_hold_batch',
1897 api_name => 'open-ils.circ.hold.reset.batch'
1900 sub reset_hold_batch {
1901 my($self, $conn, $auth, $hold_ids) = @_;
1903 my $e = new_editor(authtoken => $auth);
1904 return $e->event unless $e->checkauth;
1906 for my $hold_id ($hold_ids) {
1908 my $hold = $e->retrieve_action_hold_request(
1909 [$hold_id, {flesh => 1, flesh_fields => {ahr => ['usr']}}])
1910 or return $e->event;
1912 next unless $e->allowed('UPDATE_HOLD', $hold->usr->home_ou);
1913 _reset_hold($self, $e->requestor, $hold);
1921 my ($self, $reqr, $hold) = @_;
1923 my $e = new_editor(xact =>1, requestor => $reqr);
1925 $logger->info("reseting hold ".$hold->id);
1927 my $hid = $hold->id;
1929 if( $hold->capture_time and $hold->current_copy ) {
1931 my $copy = $e->retrieve_asset_copy($hold->current_copy)
1932 or return $e->die_event;
1934 if( $copy->status == OILS_COPY_STATUS_ON_HOLDS_SHELF ) {
1935 $logger->info("setting copy to status 'reshelving' on hold retarget");
1936 $copy->status(OILS_COPY_STATUS_RESHELVING);
1937 $copy->editor($e->requestor->id);
1938 $copy->edit_date('now');
1939 $e->update_asset_copy($copy) or return $e->die_event;
1941 } elsif( $copy->status == OILS_COPY_STATUS_IN_TRANSIT ) {
1943 # We don't want the copy to remain "in transit"
1944 $copy->status(OILS_COPY_STATUS_RESHELVING);
1945 $logger->warn("! reseting hold [$hid] that is in transit");
1946 my $transid = $e->search_action_hold_transit_copy({hold=>$hold->id},{idlist=>1})->[0];
1949 my $trans = $e->retrieve_action_transit_copy($transid);
1951 $logger->info("Aborting transit [$transid] on hold [$hid] reset...");
1952 my $evt = OpenILS::Application::Circ::Transit::__abort_transit($e, $trans, $copy, 1, 1);
1953 $logger->info("Transit abort completed with result $evt");
1954 unless ("$evt" eq 1) {
1963 $hold->clear_capture_time;
1964 $hold->clear_current_copy;
1965 $hold->clear_shelf_time;
1966 $hold->clear_shelf_expire_time;
1967 $hold->clear_current_shelf_lib;
1969 $e->update_action_hold_request($hold) or return $e->die_event;
1973 'open-ils.storage.action.hold_request.copy_targeter', undef, $hold->id );
1979 __PACKAGE__->register_method(
1980 method => 'fetch_open_title_holds',
1981 api_name => 'open-ils.circ.open_holds.retrieve',
1983 Returns a list ids of un-fulfilled holds for a given title id
1984 @param authtoken The login session key
1985 @param id the id of the item whose holds we want to retrieve
1986 @param type The hold type - M, T, I, V, C, F, R
1990 sub fetch_open_title_holds {
1991 my( $self, $conn, $auth, $id, $type, $org ) = @_;
1992 my $e = new_editor( authtoken => $auth );
1993 return $e->event unless $e->checkauth;
1996 $org ||= $e->requestor->ws_ou;
1998 # return $e->search_action_hold_request(
1999 # { target => $id, hold_type => $type, fulfillment_time => undef }, {idlist=>1});
2001 # XXX make me return IDs in the future ^--
2002 my $holds = $e->search_action_hold_request(
2005 cancel_time => undef,
2007 fulfillment_time => undef
2011 flesh_hold_transits($holds);
2016 sub flesh_hold_transits {
2018 for my $hold ( @$holds ) {
2020 $apputils->simplereq(
2022 "open-ils.cstore.direct.action.hold_transit_copy.search.atomic",
2023 { hold => $hold->id },
2024 { order_by => { ahtc => 'id desc' }, limit => 1 }
2030 sub flesh_hold_notices {
2031 my( $holds, $e ) = @_;
2032 $e ||= new_editor();
2034 for my $hold (@$holds) {
2035 my $notices = $e->search_action_hold_notification(
2037 { hold => $hold->id },
2038 { order_by => { anh => 'notify_time desc' } },
2043 $hold->notify_count(scalar(@$notices));
2045 my $n = $e->retrieve_action_hold_notification($$notices[0])
2046 or return $e->event;
2047 $hold->notify_time($n->notify_time);
2053 __PACKAGE__->register_method(
2054 method => 'fetch_captured_holds',
2055 api_name => 'open-ils.circ.captured_holds.on_shelf.retrieve',
2059 Returns a list of un-fulfilled holds (on the Holds Shelf) for a given title id
2060 @param authtoken The login session key
2061 @param org The org id of the location in question
2062 @param match_copy A specific copy to limit to
2066 __PACKAGE__->register_method(
2067 method => 'fetch_captured_holds',
2068 api_name => 'open-ils.circ.captured_holds.id_list.on_shelf.retrieve',
2072 Returns list ids of un-fulfilled holds (on the Holds Shelf) for a given title id
2073 @param authtoken The login session key
2074 @param org The org id of the location in question
2075 @param match_copy A specific copy to limit to
2079 __PACKAGE__->register_method(
2080 method => 'fetch_captured_holds',
2081 api_name => 'open-ils.circ.captured_holds.id_list.expired_on_shelf.retrieve',
2085 Returns list ids of shelf-expired un-fulfilled holds for a given title id
2086 @param authtoken The login session key
2087 @param org The org id of the location in question
2088 @param match_copy A specific copy to limit to
2092 __PACKAGE__->register_method(
2093 method => 'fetch_captured_holds',
2095 'open-ils.circ.captured_holds.expired_on_shelf_or_wrong_shelf.retrieve',
2099 Returns list of shelf-expired un-fulfilled holds OR wrong shelf holds
2100 for a given shelf lib
2104 __PACKAGE__->register_method(
2105 method => 'fetch_captured_holds',
2107 'open-ils.circ.captured_holds.id_list.expired_on_shelf_or_wrong_shelf.retrieve',
2111 Returns list of shelf-expired un-fulfilled holds OR wrong shelf holds
2112 for a given shelf lib
2117 sub fetch_captured_holds {
2118 my( $self, $conn, $auth, $org, $match_copy ) = @_;
2120 my $e = new_editor(authtoken => $auth);
2121 return $e->die_event unless $e->checkauth;
2122 return $e->die_event unless $e->allowed('VIEW_HOLD'); # XXX rely on editor perm
2124 $org ||= $e->requestor->ws_ou;
2126 my $current_copy = { '!=' => undef };
2127 $current_copy = { '=' => $match_copy } if $match_copy;
2130 select => { alhr => ['id'] },
2135 fkey => 'current_copy'
2140 '+acp' => { status => OILS_COPY_STATUS_ON_HOLDS_SHELF, deleted => 'f' },
2142 capture_time => { "!=" => undef },
2143 current_copy => $current_copy,
2144 fulfillment_time => undef,
2145 current_shelf_lib => $org
2149 if($self->api_name =~ /expired/) {
2150 $query->{'where'}->{'+alhr'}->{'-or'} = {
2151 shelf_expire_time => { '<' => 'today'},
2152 cancel_time => { '!=' => undef },
2155 my $hold_ids = $e->json_query( $query );
2157 if ($self->api_name =~ /wrong_shelf/) {
2158 # fetch holds whose current_shelf_lib is $org, but whose pickup
2159 # lib is some other org unit. Ignore already-retrieved holds.
2161 pickup_lib_changed_on_shelf_holds(
2162 $e, $org, [map {$_->{id}} @$hold_ids]);
2163 # match the layout of other items in $hold_ids
2164 push (@$hold_ids, {id => $_}) for @$wrong_shelf;
2168 for my $hold_id (@$hold_ids) {
2169 if($self->api_name =~ /id_list/) {
2170 $conn->respond($hold_id->{id});
2174 $e->retrieve_action_hold_request([
2178 flesh_fields => {ahr => ['notifications', 'transit', 'notes']},
2179 order_by => {anh => 'notify_time desc'}
2189 __PACKAGE__->register_method(
2190 method => "print_expired_holds_stream",
2191 api_name => "open-ils.circ.captured_holds.expired.print.stream",
2195 sub print_expired_holds_stream {
2196 my ($self, $client, $auth, $params) = @_;
2198 # No need to check specific permissions: we're going to call another method
2199 # that will do that.
2200 my $e = new_editor("authtoken" => $auth);
2201 return $e->die_event unless $e->checkauth;
2203 delete($$params{org_id}) unless (int($$params{org_id}));
2204 delete($$params{limit}) unless (int($$params{limit}));
2205 delete($$params{offset}) unless (int($$params{offset}));
2206 delete($$params{chunk_size}) unless (int($$params{chunk_size}));
2207 delete($$params{chunk_size}) if ($$params{chunk_size} && $$params{chunk_size} > 50); # keep the size reasonable
2208 $$params{chunk_size} ||= 10;
2210 $$params{org_id} = (defined $$params{org_id}) ? $$params{org_id}: $e->requestor->ws_ou;
2212 my @hold_ids = $self->method_lookup(
2213 "open-ils.circ.captured_holds.id_list.expired_on_shelf.retrieve"
2214 )->run($auth, $params->{"org_id"});
2219 } elsif (defined $U->event_code($hold_ids[0])) {
2221 return $hold_ids[0];
2224 $logger->info("about to stream back up to " . scalar(@hold_ids) . " expired holds");
2227 my @hid_chunk = splice @hold_ids, 0, $params->{"chunk_size"};
2229 my $result_chunk = $e->json_query({
2231 "acp" => ["barcode"],
2233 first_given_name second_given_name family_name alias
2242 "field" => "id", "fkey" => "current_copy",
2245 "field" => "id", "fkey" => "call_number",
2248 "field" => "id", "fkey" => "record"
2252 "acpl" => {"field" => "id", "fkey" => "location"}
2255 "au" => {"field" => "id", "fkey" => "usr"}
2258 "where" => {"+ahr" => {"id" => \@hid_chunk}}
2259 }) or return $e->die_event;
2260 $client->respond($result_chunk);
2267 __PACKAGE__->register_method(
2268 method => "check_title_hold_batch",
2269 api_name => "open-ils.circ.title_hold.is_possible.batch",
2272 desc => '@see open-ils.circ.title_hold.is_possible.batch',
2274 { desc => 'Authentication token', type => 'string'},
2275 { desc => 'Array of Hash of named parameters', type => 'array'},
2278 desc => 'Array of response objects',
2284 sub check_title_hold_batch {
2285 my($self, $client, $authtoken, $param_list, $oargs) = @_;
2286 foreach (@$param_list) {
2287 my ($res) = $self->method_lookup('open-ils.circ.title_hold.is_possible')->run($authtoken, $_, $oargs);
2288 $client->respond($res);
2294 __PACKAGE__->register_method(
2295 method => "check_title_hold",
2296 api_name => "open-ils.circ.title_hold.is_possible",
2298 desc => 'Determines if a hold were to be placed by a given user, ' .
2299 'whether or not said hold would have any potential copies to fulfill it.' .
2300 'The named paramaters of the second argument include: ' .
2301 'patronid, titleid, volume_id, copy_id, mrid, depth, pickup_lib, hold_type, selection_ou. ' .
2302 'See perldoc ' . __PACKAGE__ . ' for more info on these fields.' ,
2304 { desc => 'Authentication token', type => 'string'},
2305 { desc => 'Hash of named parameters', type => 'object'},
2308 desc => 'List of new message IDs (empty if none)',
2314 =head3 check_title_hold (token, hash)
2316 The named fields in the hash are:
2318 patronid - ID of the hold recipient (required)
2319 depth - hold range depth (default 0)
2320 pickup_lib - destination for hold, fallback value for selection_ou
2321 selection_ou - ID of org_unit establishing hard and soft hold boundary settings
2322 issuanceid - ID of the issuance to be held, required for Issuance level hold
2323 partid - ID of the monograph part to be held, required for monograph part level hold
2324 titleid - ID (BRN) of the title to be held, required for Title level hold
2325 volume_id - required for Volume level hold
2326 copy_id - required for Copy level hold
2327 mrid - required for Meta-record level hold
2328 hold_type - T, C (or R or F), I, V or M for Title, Copy, Issuance, Volume or Meta-record (default "T")
2330 All key/value pairs are passed on to do_possibility_checks.
2334 # FIXME: better params checking. what other params are required, if any?
2335 # FIXME: 3 copies of values confusing: $x, $params->{x} and $params{x}
2336 # FIXME: for example, $depth gets a default value, but then $$params{depth} is still
2337 # used in conditionals, where it may be undefined, causing a warning.
2338 # FIXME: specify proper usage/interaction of selection_ou and pickup_lib
2340 sub check_title_hold {
2341 my( $self, $client, $authtoken, $params ) = @_;
2342 my $e = new_editor(authtoken=>$authtoken);
2343 return $e->event unless $e->checkauth;
2345 my %params = %$params;
2346 my $depth = $params{depth} || 0;
2347 my $selection_ou = $params{selection_ou} || $params{pickup_lib};
2348 my $oargs = $params{oargs} || {};
2350 if($oargs->{events}) {
2351 @{$oargs->{events}} = grep { $e->allowed($_ . '.override', $e->requestor->ws_ou); } @{$oargs->{events}};
2355 my $patron = $e->retrieve_actor_user($params{patronid})
2356 or return $e->event;
2358 if( $e->requestor->id ne $patron->id ) {
2359 return $e->event unless
2360 $e->allowed('VIEW_HOLD_PERMIT', $patron->home_ou);
2363 return OpenILS::Event->new('PATRON_BARRED') if $U->is_true($patron->barred);
2365 my $request_lib = $e->retrieve_actor_org_unit($e->requestor->ws_ou)
2366 or return $e->event;
2368 my $soft_boundary = $U->ou_ancestor_setting_value($selection_ou, OILS_SETTING_HOLD_SOFT_BOUNDARY);
2369 my $hard_boundary = $U->ou_ancestor_setting_value($selection_ou, OILS_SETTING_HOLD_HARD_BOUNDARY);
2372 my $return_depth = $hard_boundary; # default depth to return on success
2373 if(defined $soft_boundary and $depth < $soft_boundary) {
2374 # work up the tree and as soon as we find a potential copy, use that depth
2375 # also, make sure we don't go past the hard boundary if it exists
2377 # our min boundary is the greater of user-specified boundary or hard boundary
2378 my $min_depth = (defined $hard_boundary and $hard_boundary > $depth) ?
2379 $hard_boundary : $depth;
2381 my $depth = $soft_boundary;
2382 while($depth >= $min_depth) {
2383 $logger->info("performing hold possibility check with soft boundary $depth");
2384 @status = do_possibility_checks($e, $patron, $request_lib, $depth, %params);
2386 $return_depth = $depth;
2391 } elsif(defined $hard_boundary and $depth < $hard_boundary) {
2392 # there is no soft boundary, enforce the hard boundary if it exists
2393 $logger->info("performing hold possibility check with hard boundary $hard_boundary");
2394 @status = do_possibility_checks($e, $patron, $request_lib, $hard_boundary, %params);
2396 # no boundaries defined, fall back to user specifed boundary or no boundary
2397 $logger->info("performing hold possibility check with no boundary");
2398 @status = do_possibility_checks($e, $patron, $request_lib, $params{depth}, %params);
2401 my $place_unfillable = 0;
2402 $place_unfillable = 1 if $e->allowed('PLACE_UNFILLABLE_HOLD', $e->requestor->ws_ou);
2407 "depth" => $return_depth,
2408 "local_avail" => $status[1]
2410 } elsif ($status[2]) {
2411 my $n = scalar @{$status[2]};
2412 return {"success" => 0, "last_event" => $status[2]->[$n - 1], "age_protected_copy" => $status[3], "place_unfillable" => $place_unfillable};
2414 return {"success" => 0, "age_protected_copy" => $status[3], "place_unfillable" => $place_unfillable};
2420 sub do_possibility_checks {
2421 my($e, $patron, $request_lib, $depth, %params) = @_;
2423 my $issuanceid = $params{issuanceid} || "";
2424 my $partid = $params{partid} || "";
2425 my $titleid = $params{titleid} || "";
2426 my $volid = $params{volume_id};
2427 my $copyid = $params{copy_id};
2428 my $mrid = $params{mrid} || "";
2429 my $pickup_lib = $params{pickup_lib};
2430 my $hold_type = $params{hold_type} || 'T';
2431 my $selection_ou = $params{selection_ou} || $pickup_lib;
2432 my $holdable_formats = $params{holdable_formats};
2433 my $oargs = $params{oargs} || {};
2440 if( $hold_type eq OILS_HOLD_TYPE_FORCE || $hold_type eq OILS_HOLD_TYPE_RECALL || $hold_type eq OILS_HOLD_TYPE_COPY ) {
2442 return $e->event unless $copy = $e->retrieve_asset_copy($copyid);
2443 return $e->event unless $volume = $e->retrieve_asset_call_number($copy->call_number);
2444 return $e->event unless $title = $e->retrieve_biblio_record_entry($volume->record);
2446 return (1, 1, []) if( $hold_type eq OILS_HOLD_TYPE_RECALL || $hold_type eq OILS_HOLD_TYPE_FORCE);
2447 return verify_copy_for_hold(
2448 $patron, $e->requestor, $title, $copy, $pickup_lib, $request_lib, $oargs
2451 } elsif( $hold_type eq OILS_HOLD_TYPE_VOLUME ) {
2453 return $e->event unless $volume = $e->retrieve_asset_call_number($volid);
2454 return $e->event unless $title = $e->retrieve_biblio_record_entry($volume->record);
2456 return _check_volume_hold_is_possible(
2457 $volume, $title, $depth, $request_lib, $patron, $e->requestor, $pickup_lib, $selection_ou, $oargs
2460 } elsif( $hold_type eq OILS_HOLD_TYPE_TITLE ) {
2462 return _check_title_hold_is_possible(
2463 $titleid, $depth, $request_lib, $patron, $e->requestor, $pickup_lib, $selection_ou, undef, $oargs
2466 } elsif( $hold_type eq OILS_HOLD_TYPE_ISSUANCE ) {
2468 return _check_issuance_hold_is_possible(
2469 $issuanceid, $depth, $request_lib, $patron, $e->requestor, $pickup_lib, $selection_ou, $oargs
2472 } elsif( $hold_type eq OILS_HOLD_TYPE_MONOPART ) {
2474 return _check_monopart_hold_is_possible(
2475 $partid, $depth, $request_lib, $patron, $e->requestor, $pickup_lib, $selection_ou, $oargs
2478 } elsif( $hold_type eq OILS_HOLD_TYPE_METARECORD ) {
2480 # pasing undef as the depth to filtered_records causes the depth
2481 # of the selection_ou to be used, which is not what we want here.
2484 my ($recs) = __PACKAGE__->method_lookup('open-ils.circ.holds.metarecord.filtered_records')->run($mrid, $holdable_formats, $selection_ou, $depth);
2486 for my $rec (@$recs) {
2487 @status = _check_title_hold_is_possible(
2488 $rec, $depth, $request_lib, $patron, $e->requestor, $pickup_lib, $selection_ou, $holdable_formats, $oargs
2494 # else { Unrecognized hold_type ! } # FIXME: return error? or 0?
2497 sub MR_filter_records {
2504 my $opac_visible = shift;
2506 my $org_at_depth = defined($d) ? $U->org_unit_ancestor_at_depth($o, $d) : $o;
2507 return $U->storagereq(
2508 'open-ils.storage.metarecord.filtered_records.atomic',
2509 $m, $f, $org_at_depth, $opac_visible
2512 __PACKAGE__->register_method(
2513 method => 'MR_filter_records',
2514 api_name => 'open-ils.circ.holds.metarecord.filtered_records',
2519 sub create_ranged_org_filter {
2520 my($e, $selection_ou, $depth) = @_;
2522 # find the orgs from which this hold may be fulfilled,
2523 # based on the selection_ou and depth
2525 my $top_org = $e->search_actor_org_unit([
2526 {parent_ou => undef},
2527 {flesh=>1, flesh_fields=>{aou=>['ou_type']}}])->[0];
2530 return () if $depth == $top_org->ou_type->depth;
2532 my $org_list = $U->storagereq('open-ils.storage.actor.org_unit.descendants.atomic', $selection_ou, $depth);
2533 %org_filter = (circ_lib => []);
2534 push(@{$org_filter{circ_lib}}, $_->id) for @$org_list;
2536 $logger->info("hold org filter at depth $depth and selection_ou ".
2537 "$selection_ou created list of @{$org_filter{circ_lib}}");
2543 sub _check_title_hold_is_possible {
2544 my( $titleid, $depth, $request_lib, $patron, $requestor, $pickup_lib, $selection_ou, $holdable_formats, $oargs ) = @_;
2545 # $holdable_formats is now unused. We pre-filter the MR's records.
2547 my $e = new_editor();
2548 my %org_filter = create_ranged_org_filter($e, $selection_ou, $depth);
2550 # this monster will grab the id and circ_lib of all of the "holdable" copies for the given record
2551 my $copies = $e->json_query(
2553 select => { acp => ['id', 'circ_lib'] },
2558 fkey => 'call_number',
2559 filter => { record => $titleid }
2561 acpl => { field => 'id', filter => { holdable => 't'}, fkey => 'location' },
2562 ccs => { field => 'id', filter => { holdable => 't'}, fkey => 'status' },
2563 acpm => { field => 'target_copy', type => 'left' } # ignore part-linked copies
2567 '+acp' => { circulate => 't', deleted => 'f', holdable => 't', %org_filter },
2568 '+acpm' => { target_copy => undef } # ignore part-linked copies
2573 $logger->info("title possible found ".scalar(@$copies)." potential copies");
2577 "HIGH_LEVEL_HOLD_HAS_NO_COPIES",
2578 "payload" => {"fail_part" => "no_ultimate_items"}
2583 # -----------------------------------------------------------------------
2584 # sort the copies into buckets based on their circ_lib proximity to
2585 # the patron's home_ou.
2586 # -----------------------------------------------------------------------
2588 my $home_org = $patron->home_ou;
2589 my $req_org = $request_lib->id;
2591 $logger->info("prox cache $home_org " . $prox_cache{$home_org});
2593 $prox_cache{$home_org} =
2594 $e->search_actor_org_unit_proximity({from_org => $home_org})
2595 unless $prox_cache{$home_org};
2596 my $home_prox = $prox_cache{$home_org};
2599 my %hash = map { ($_->to_org => $_->prox) } @$home_prox;
2600 push( @{$buckets{ $hash{$_->{circ_lib}} } }, $_->{id} ) for @$copies;
2602 my @keys = sort { $a <=> $b } keys %buckets;
2605 if( $home_org ne $req_org ) {
2606 # -----------------------------------------------------------------------
2607 # shove the copies close to the request_lib into the primary buckets
2608 # directly before the farthest away copies. That way, they are not
2609 # given priority, but they are checked before the farthest copies.
2610 # -----------------------------------------------------------------------
2611 $prox_cache{$req_org} =
2612 $e->search_actor_org_unit_proximity({from_org => $req_org})
2613 unless $prox_cache{$req_org};
2614 my $req_prox = $prox_cache{$req_org};
2617 my %hash2 = map { ($_->to_org => $_->prox) } @$req_prox;
2618 push( @{$buckets2{ $hash2{$_->{circ_lib}} } }, $_->{id} ) for @$copies;
2620 my $highest_key = $keys[@keys - 1]; # the farthest prox in the exising buckets
2621 my $new_key = $highest_key - 0.5; # right before the farthest prox
2622 my @keys2 = sort { $a <=> $b } keys %buckets2;
2623 for my $key (@keys2) {
2624 last if $key >= $highest_key;
2625 push( @{$buckets{$new_key}}, $_ ) for @{$buckets2{$key}};
2629 @keys = sort { $a <=> $b } keys %buckets;
2634 my $age_protect_only = 0;
2635 OUTER: for my $key (@keys) {
2636 my @cps = @{$buckets{$key}};
2638 $logger->info("looking at " . scalar(@{$buckets{$key}}). " copies in proximity bucket $key");
2640 for my $copyid (@cps) {
2642 next if $seen{$copyid};
2643 $seen{$copyid} = 1; # there could be dupes given the merged buckets
2644 my $copy = $e->retrieve_asset_copy($copyid);
2645 $logger->debug("looking at bucket_key=$key, copy $copyid : circ_lib = " . $copy->circ_lib);
2647 unless($title) { # grab the title if we don't already have it
2648 my $vol = $e->retrieve_asset_call_number(
2649 [ $copy->call_number, { flesh => 1, flesh_fields => { bre => ['fixed_fields'], acn => ['record'] } } ] );
2650 $title = $vol->record;
2653 @status = verify_copy_for_hold(
2654 $patron, $requestor, $title, $copy, $pickup_lib, $request_lib, $oargs);
2656 $age_protect_only ||= $status[3];
2657 last OUTER if $status[0];
2661 $status[3] = $age_protect_only;
2665 sub _check_issuance_hold_is_possible {
2666 my( $issuanceid, $depth, $request_lib, $patron, $requestor, $pickup_lib, $selection_ou, $oargs ) = @_;
2668 my $e = new_editor();
2669 my %org_filter = create_ranged_org_filter($e, $selection_ou, $depth);
2671 # this monster will grab the id and circ_lib of all of the "holdable" copies for the given record
2672 my $copies = $e->json_query(
2674 select => { acp => ['id', 'circ_lib'] },
2680 filter => { issuance => $issuanceid }
2682 acpl => { field => 'id', filter => { holdable => 't'}, fkey => 'location' },
2683 ccs => { field => 'id', filter => { holdable => 't'}, fkey => 'status' }
2687 '+acp' => { circulate => 't', deleted => 'f', holdable => 't', %org_filter }
2693 $logger->info("issuance possible found ".scalar(@$copies)." potential copies");
2697 $empty_ok = $e->retrieve_config_global_flag('circ.holds.empty_issuance_ok');
2698 $empty_ok = ($empty_ok and $U->is_true($empty_ok->enabled));
2703 "HIGH_LEVEL_HOLD_HAS_NO_COPIES",
2704 "payload" => {"fail_part" => "no_ultimate_items"}
2712 # -----------------------------------------------------------------------
2713 # sort the copies into buckets based on their circ_lib proximity to
2714 # the patron's home_ou.
2715 # -----------------------------------------------------------------------
2717 my $home_org = $patron->home_ou;
2718 my $req_org = $request_lib->id;
2720 $logger->info("prox cache $home_org " . $prox_cache{$home_org});
2722 $prox_cache{$home_org} =
2723 $e->search_actor_org_unit_proximity({from_org => $home_org})
2724 unless $prox_cache{$home_org};
2725 my $home_prox = $prox_cache{$home_org};
2728 my %hash = map { ($_->to_org => $_->prox) } @$home_prox;
2729 push( @{$buckets{ $hash{$_->{circ_lib}} } }, $_->{id} ) for @$copies;
2731 my @keys = sort { $a <=> $b } keys %buckets;
2734 if( $home_org ne $req_org ) {
2735 # -----------------------------------------------------------------------
2736 # shove the copies close to the request_lib into the primary buckets
2737 # directly before the farthest away copies. That way, they are not
2738 # given priority, but they are checked before the farthest copies.
2739 # -----------------------------------------------------------------------
2740 $prox_cache{$req_org} =
2741 $e->search_actor_org_unit_proximity({from_org => $req_org})
2742 unless $prox_cache{$req_org};
2743 my $req_prox = $prox_cache{$req_org};
2746 my %hash2 = map { ($_->to_org => $_->prox) } @$req_prox;
2747 push( @{$buckets2{ $hash2{$_->{circ_lib}} } }, $_->{id} ) for @$copies;
2749 my $highest_key = $keys[@keys - 1]; # the farthest prox in the exising buckets
2750 my $new_key = $highest_key - 0.5; # right before the farthest prox
2751 my @keys2 = sort { $a <=> $b } keys %buckets2;
2752 for my $key (@keys2) {
2753 last if $key >= $highest_key;
2754 push( @{$buckets{$new_key}}, $_ ) for @{$buckets2{$key}};
2758 @keys = sort { $a <=> $b } keys %buckets;
2763 my $age_protect_only = 0;
2764 OUTER: for my $key (@keys) {
2765 my @cps = @{$buckets{$key}};
2767 $logger->info("looking at " . scalar(@{$buckets{$key}}). " copies in proximity bucket $key");
2769 for my $copyid (@cps) {
2771 next if $seen{$copyid};
2772 $seen{$copyid} = 1; # there could be dupes given the merged buckets
2773 my $copy = $e->retrieve_asset_copy($copyid);
2774 $logger->debug("looking at bucket_key=$key, copy $copyid : circ_lib = " . $copy->circ_lib);
2776 unless($title) { # grab the title if we don't already have it
2777 my $vol = $e->retrieve_asset_call_number(
2778 [ $copy->call_number, { flesh => 1, flesh_fields => { bre => ['fixed_fields'], acn => ['record'] } } ] );
2779 $title = $vol->record;
2782 @status = verify_copy_for_hold(
2783 $patron, $requestor, $title, $copy, $pickup_lib, $request_lib, $oargs);
2785 $age_protect_only ||= $status[3];
2786 last OUTER if $status[0];
2791 if (!defined($empty_ok)) {
2792 $empty_ok = $e->retrieve_config_global_flag('circ.holds.empty_issuance_ok');
2793 $empty_ok = ($empty_ok and $U->is_true($empty_ok->enabled));
2796 return (1,0) if ($empty_ok);
2798 $status[3] = $age_protect_only;
2802 sub _check_monopart_hold_is_possible {
2803 my( $partid, $depth, $request_lib, $patron, $requestor, $pickup_lib, $selection_ou, $oargs ) = @_;
2805 my $e = new_editor();
2806 my %org_filter = create_ranged_org_filter($e, $selection_ou, $depth);
2808 # this monster will grab the id and circ_lib of all of the "holdable" copies for the given record
2809 my $copies = $e->json_query(
2811 select => { acp => ['id', 'circ_lib'] },
2815 field => 'target_copy',
2817 filter => { part => $partid }
2819 acpl => { field => 'id', filter => { holdable => 't'}, fkey => 'location' },
2820 ccs => { field => 'id', filter => { holdable => 't'}, fkey => 'status' }
2824 '+acp' => { circulate => 't', deleted => 'f', holdable => 't', %org_filter }
2830 $logger->info("monopart possible found ".scalar(@$copies)." potential copies");
2834 $empty_ok = $e->retrieve_config_global_flag('circ.holds.empty_part_ok');
2835 $empty_ok = ($empty_ok and $U->is_true($empty_ok->enabled));
2840 "HIGH_LEVEL_HOLD_HAS_NO_COPIES",
2841 "payload" => {"fail_part" => "no_ultimate_items"}
2849 # -----------------------------------------------------------------------
2850 # sort the copies into buckets based on their circ_lib proximity to
2851 # the patron's home_ou.
2852 # -----------------------------------------------------------------------
2854 my $home_org = $patron->home_ou;
2855 my $req_org = $request_lib->id;
2857 $logger->info("prox cache $home_org " . $prox_cache{$home_org});
2859 $prox_cache{$home_org} =
2860 $e->search_actor_org_unit_proximity({from_org => $home_org})
2861 unless $prox_cache{$home_org};
2862 my $home_prox = $prox_cache{$home_org};
2865 my %hash = map { ($_->to_org => $_->prox) } @$home_prox;
2866 push( @{$buckets{ $hash{$_->{circ_lib}} } }, $_->{id} ) for @$copies;
2868 my @keys = sort { $a <=> $b } keys %buckets;
2871 if( $home_org ne $req_org ) {
2872 # -----------------------------------------------------------------------
2873 # shove the copies close to the request_lib into the primary buckets
2874 # directly before the farthest away copies. That way, they are not
2875 # given priority, but they are checked before the farthest copies.
2876 # -----------------------------------------------------------------------
2877 $prox_cache{$req_org} =
2878 $e->search_actor_org_unit_proximity({from_org => $req_org})
2879 unless $prox_cache{$req_org};
2880 my $req_prox = $prox_cache{$req_org};
2883 my %hash2 = map { ($_->to_org => $_->prox) } @$req_prox;
2884 push( @{$buckets2{ $hash2{$_->{circ_lib}} } }, $_->{id} ) for @$copies;
2886 my $highest_key = $keys[@keys - 1]; # the farthest prox in the exising buckets
2887 my $new_key = $highest_key - 0.5; # right before the farthest prox
2888 my @keys2 = sort { $a <=> $b } keys %buckets2;
2889 for my $key (@keys2) {
2890 last if $key >= $highest_key;
2891 push( @{$buckets{$new_key}}, $_ ) for @{$buckets2{$key}};
2895 @keys = sort { $a <=> $b } keys %buckets;
2900 my $age_protect_only = 0;
2901 OUTER: for my $key (@keys) {
2902 my @cps = @{$buckets{$key}};
2904 $logger->info("looking at " . scalar(@{$buckets{$key}}). " copies in proximity bucket $key");
2906 for my $copyid (@cps) {
2908 next if $seen{$copyid};
2909 $seen{$copyid} = 1; # there could be dupes given the merged buckets
2910 my $copy = $e->retrieve_asset_copy($copyid);
2911 $logger->debug("looking at bucket_key=$key, copy $copyid : circ_lib = " . $copy->circ_lib);
2913 unless($title) { # grab the title if we don't already have it
2914 my $vol = $e->retrieve_asset_call_number(
2915 [ $copy->call_number, { flesh => 1, flesh_fields => { bre => ['fixed_fields'], acn => ['record'] } } ] );
2916 $title = $vol->record;
2919 @status = verify_copy_for_hold(
2920 $patron, $requestor, $title, $copy, $pickup_lib, $request_lib, $oargs);
2922 $age_protect_only ||= $status[3];
2923 last OUTER if $status[0];
2928 if (!defined($empty_ok)) {
2929 $empty_ok = $e->retrieve_config_global_flag('circ.holds.empty_part_ok');
2930 $empty_ok = ($empty_ok and $U->is_true($empty_ok->enabled));
2933 return (1,0) if ($empty_ok);
2935 $status[3] = $age_protect_only;
2940 sub _check_volume_hold_is_possible {
2941 my( $vol, $title, $depth, $request_lib, $patron, $requestor, $pickup_lib, $selection_ou, $oargs ) = @_;
2942 my %org_filter = create_ranged_org_filter(new_editor(), $selection_ou, $depth);
2943 my $copies = new_editor->search_asset_copy({call_number => $vol->id, %org_filter});
2944 $logger->info("checking possibility of volume hold for volume ".$vol->id);
2946 my $filter_copies = [];
2947 for my $copy (@$copies) {
2948 # ignore part-mapped copies for regular volume level holds
2949 push(@$filter_copies, $copy) unless
2950 new_editor->search_asset_copy_part_map({target_copy => $copy->id})->[0];
2952 $copies = $filter_copies;
2957 "HIGH_LEVEL_HOLD_HAS_NO_COPIES",
2958 "payload" => {"fail_part" => "no_ultimate_items"}
2964 my $age_protect_only = 0;
2965 for my $copy ( @$copies ) {
2966 @status = verify_copy_for_hold(
2967 $patron, $requestor, $title, $copy, $pickup_lib, $request_lib, $oargs );
2968 $age_protect_only ||= $status[3];
2971 $status[3] = $age_protect_only;
2977 sub verify_copy_for_hold {
2978 my( $patron, $requestor, $title, $copy, $pickup_lib, $request_lib, $oargs ) = @_;
2979 # $oargs should be undef unless we're overriding.
2980 $logger->info("checking possibility of copy in hold request for copy ".$copy->id);
2981 my $permitted = OpenILS::Utils::PermitHold::permit_copy_hold(
2984 requestor => $requestor,
2987 title_descriptor => $title->fixed_fields,
2988 pickup_lib => $pickup_lib,
2989 request_lib => $request_lib,
2991 show_event_list => 1
2995 # Check for override permissions on events.
2996 if ($oargs && $permitted && scalar @$permitted) {
2997 # Remove the events from permitted that we can override.
2998 if ($oargs->{events}) {
2999 foreach my $evt (@{$oargs->{events}}) {
3000 $permitted = [grep {$_->{textcode} ne $evt} @{$permitted}];
3003 # Now, we handle the override all case by checking remaining
3004 # events against override permissions.
3005 if (scalar @$permitted && $oargs->{all}) {
3006 # Pre-set events and failed members of oargs to empty
3007 # arrays, if they are not set, yet.
3008 $oargs->{events} = [] unless ($oargs->{events});
3009 $oargs->{failed} = [] unless ($oargs->{failed});
3010 # When we're done with these checks, we swap permitted
3011 # with a reference to @disallowed.
3012 my @disallowed = ();
3013 foreach my $evt (@{$permitted}) {
3014 # Check if we've already seen the event in this
3015 # session and it failed.
3016 if (grep {$_ eq $evt->{textcode}} @{$oargs->{failed}}) {
3017 push(@disallowed, $evt);
3019 # We have to check if the requestor has the
3020 # override permission.
3022 # AppUtils::check_user_perms returns the perm if
3023 # the user doesn't have it, undef if they do.
3024 if ($apputils->check_user_perms($requestor->id, $requestor->ws_ou, $evt->{textcode} . '.override')) {
3025 push(@disallowed, $evt);
3026 push(@{$oargs->{failed}}, $evt->{textcode});
3028 push(@{$oargs->{events}}, $evt->{textcode});
3032 $permitted = \@disallowed;
3036 my $age_protect_only = 0;
3037 if (@$permitted == 1 && @$permitted[0]->{textcode} eq 'ITEM_AGE_PROTECTED') {
3038 $age_protect_only = 1;
3042 (not scalar @$permitted), # true if permitted is an empty arrayref
3043 ( # XXX This test is of very dubious value; someone should figure
3044 # out what if anything is checking this value
3045 ($copy->circ_lib == $pickup_lib) and
3046 ($copy->status == OILS_COPY_STATUS_AVAILABLE)
3055 sub find_nearest_permitted_hold {
3058 my $editor = shift; # CStoreEditor object
3059 my $copy = shift; # copy to target
3060 my $user = shift; # staff
3061 my $check_only = shift; # do no updates, just see if the copy could fulfill a hold
3063 my $evt = OpenILS::Event->new('ACTION_HOLD_REQUEST_NOT_FOUND');
3065 my $bc = $copy->barcode;
3067 # find any existing holds that already target this copy
3068 my $old_holds = $editor->search_action_hold_request(
3069 { current_copy => $copy->id,
3070 cancel_time => undef,
3071 capture_time => undef
3075 my $hold_stall_interval = $U->ou_ancestor_setting_value($user->ws_ou, OILS_SETTING_HOLD_SOFT_STALL);
3077 $logger->info("circulator: searching for best hold at org ".$user->ws_ou.
3078 " and copy $bc with a hold stalling interval of ". ($hold_stall_interval || "(none)"));
3080 my $fifo = $U->ou_ancestor_setting_value($user->ws_ou, 'circ.holds_fifo');
3082 # the nearest_hold API call now needs this
3083 $copy->call_number($editor->retrieve_asset_call_number($copy->call_number))
3084 unless ref $copy->call_number;
3086 # search for what should be the best holds for this copy to fulfill
3087 my $best_holds = $U->storagereq(
3088 "open-ils.storage.action.hold_request.nearest_hold.atomic",
3089 $user->ws_ou, $copy, 100, $hold_stall_interval, $fifo );
3091 # Add any pre-targeted holds to the list too? Unless they are already there, anyway.
3093 for my $holdid (@$old_holds) {
3094 next unless $holdid;
3095 push(@$best_holds, $holdid) unless ( grep { ''.$holdid eq ''.$_ } @$best_holds );
3099 unless(@$best_holds) {
3100 $logger->info("circulator: no suitable holds found for copy $bc");
3101 return (undef, $evt);
3107 # for each potential hold, we have to run the permit script
3108 # to make sure the hold is actually permitted.
3111 for my $holdid (@$best_holds) {
3112 next unless $holdid;
3113 $logger->info("circulator: checking if hold $holdid is permitted for copy $bc");
3115 my $hold = $editor->retrieve_action_hold_request($holdid) or next;
3116 # Force and recall holds bypass all rules
3117 if ($hold->hold_type eq 'R' || $hold->hold_type eq 'F') {
3121 my $reqr = $reqr_cache{$hold->requestor} || $editor->retrieve_actor_user($hold->requestor);
3122 my $rlib = $org_cache{$hold->request_lib} || $editor->retrieve_actor_org_unit($hold->request_lib);
3124 $reqr_cache{$hold->requestor} = $reqr;
3125 $org_cache{$hold->request_lib} = $rlib;
3127 # see if this hold is permitted
3128 my $permitted = OpenILS::Utils::PermitHold::permit_copy_hold(
3130 patron_id => $hold->usr,
3133 pickup_lib => $hold->pickup_lib,
3134 request_lib => $rlib,
3146 unless( $best_hold ) { # no "good" permitted holds were found
3148 $logger->info("circulator: no suitable holds found for copy $bc");
3149 return (undef, $evt);
3152 $logger->info("circulator: best hold ".$best_hold->id." found for copy $bc");
3154 # indicate a permitted hold was found
3155 return $best_hold if $check_only;
3157 # we've found a permitted hold. we need to "grab" the copy
3158 # to prevent re-targeted holds (next part) from re-grabbing the copy
3159 $best_hold->current_copy($copy->id);
3160 $editor->update_action_hold_request($best_hold)
3161 or return (undef, $editor->event);
3166 # re-target any other holds that already target this copy
3167 for my $old_hold (@$old_holds) {
3168 next if $old_hold->id eq $best_hold->id; # don't re-target the hold we want
3169 $logger->info("circulator: clearing current_copy and prev_check_time on hold ".
3170 $old_hold->id." after a better hold [".$best_hold->id."] was found");
3171 $old_hold->clear_current_copy;
3172 $old_hold->clear_prev_check_time;
3173 $editor->update_action_hold_request($old_hold)
3174 or return (undef, $editor->event);
3175 push(@retarget, $old_hold->id);
3178 return ($best_hold, undef, (@retarget) ? \@retarget : undef);
3186 __PACKAGE__->register_method(
3187 method => 'all_rec_holds',
3188 api_name => 'open-ils.circ.holds.retrieve_all_from_title',
3192 my( $self, $conn, $auth, $title_id, $args ) = @_;
3194 my $e = new_editor(authtoken=>$auth);
3195 $e->checkauth or return $e->event;
3196 $e->allowed('VIEW_HOLD') or return $e->event;
3199 $args->{fulfillment_time} = undef; # we don't want to see old fulfilled holds
3200 $args->{cancel_time} = undef;
3203 metarecord_holds => []
3205 , volume_holds => []
3207 , recall_holds => []
3210 , issuance_holds => []
3213 my $mr_map = $e->search_metabib_metarecord_source_map({source => $title_id})->[0];
3215 $resp->{metarecord_holds} = $e->search_action_hold_request(
3216 { hold_type => OILS_HOLD_TYPE_METARECORD,
3217 target => $mr_map->metarecord,
3223 $resp->{title_holds} = $e->search_action_hold_request(
3225 hold_type => OILS_HOLD_TYPE_TITLE,
3226 target => $title_id,
3230 my $parts = $e->search_biblio_monograph_part(
3236 $resp->{part_holds} = $e->search_action_hold_request(
3238 hold_type => OILS_HOLD_TYPE_MONOPART,
3244 my $subs = $e->search_serial_subscription(
3245 { record_entry => $title_id }, {idlist=>1});
3248 my $issuances = $e->search_serial_issuance(
3249 {subscription => $subs}, {idlist=>1}
3253 $resp->{issuance_holds} = $e->search_action_hold_request(
3255 hold_type => OILS_HOLD_TYPE_ISSUANCE,
3256 target => $issuances,
3263 my $vols = $e->search_asset_call_number(
3264 { record => $title_id, deleted => 'f' }, {idlist=>1});
3266 return $resp unless @$vols;
3268 $resp->{volume_holds} = $e->search_action_hold_request(
3270 hold_type => OILS_HOLD_TYPE_VOLUME,
3275 my $copies = $e->search_asset_copy(
3276 { call_number => $vols, deleted => 'f' }, {idlist=>1});
3278 return $resp unless @$copies;
3280 $resp->{copy_holds} = $e->search_action_hold_request(
3282 hold_type => OILS_HOLD_TYPE_COPY,
3287 $resp->{recall_holds} = $e->search_action_hold_request(
3289 hold_type => OILS_HOLD_TYPE_RECALL,
3294 $resp->{force_holds} = $e->search_action_hold_request(
3296 hold_type => OILS_HOLD_TYPE_FORCE,
3308 __PACKAGE__->register_method(
3309 method => 'uber_hold',
3311 api_name => 'open-ils.circ.hold.details.retrieve'
3315 my($self, $client, $auth, $hold_id, $args) = @_;
3316 my $e = new_editor(authtoken=>$auth);
3317 $e->checkauth or return $e->event;
3318 return uber_hold_impl($e, $hold_id, $args);
3321 __PACKAGE__->register_method(
3322 method => 'batch_uber_hold',
3325 api_name => 'open-ils.circ.hold.details.batch.retrieve'
3328 sub batch_uber_hold {
3329 my($self, $client, $auth, $hold_ids, $args) = @_;
3330 my $e = new_editor(authtoken=>$auth);
3331 $e->checkauth or return $e->event;
3332 $client->respond(uber_hold_impl($e, $_, $args)) for @$hold_ids;
3336 sub uber_hold_impl {
3337 my($e, $hold_id, $args) = @_;
3340 my $hold = $e->retrieve_action_hold_request(
3345 flesh_fields => { ahr => [ 'current_copy', 'usr', 'notes' ] }
3348 ) or return $e->event;
3350 if($hold->usr->id ne $e->requestor->id) {
3351 # caller is asking for someone else's hold
3352 $e->allowed('VIEW_HOLD') or return $e->event;
3353 $hold->notes( # filter out any non-staff ("private") notes (unless marked as public)
3354 [ grep { $U->is_true($_->staff) or $U->is_true($_->pub) } @{$hold->notes} ] );
3357 # caller is asking for own hold, but may not have permission to view staff notes
3358 unless($e->allowed('VIEW_HOLD')) {
3359 $hold->notes( # filter out any staff notes (unless marked as public)
3360 [ grep { !$U->is_true($_->staff) or $U->is_true($_->pub) } @{$hold->notes} ] );
3364 my $user = $hold->usr;
3365 $hold->usr($user->id);
3368 my( $mvr, $volume, $copy, $issuance, $part, $bre ) = find_hold_mvr($e, $hold, $args->{suppress_mvr});
3370 flesh_hold_notices([$hold], $e) unless $args->{suppress_notices};
3371 flesh_hold_transits([$hold]) unless $args->{suppress_transits};
3373 my $details = retrieve_hold_queue_status_impl($e, $hold);
3378 ($copy ? (copy => $copy) : ()),
3379 ($volume ? (volume => $volume) : ()),
3380 ($issuance ? (issuance => $issuance) : ()),
3381 ($part ? (part => $part) : ()),
3382 ($args->{include_bre} ? (bre => $bre) : ()),
3383 ($args->{suppress_mvr} ? () : (mvr => $mvr)),
3387 unless($args->{suppress_patron_details}) {
3388 my $card = $e->retrieve_actor_card($user->card) or return $e->event;
3389 $resp->{patron_first} = $user->first_given_name,
3390 $resp->{patron_last} = $user->family_name,
3391 $resp->{patron_barcode} = $card->barcode,
3392 $resp->{patron_alias} = $user->alias,
3400 # -----------------------------------------------------
3401 # Returns the MVR object that represents what the
3403 # -----------------------------------------------------
3405 my( $e, $hold, $no_mvr ) = @_;
3413 if( $hold->hold_type eq OILS_HOLD_TYPE_METARECORD ) {
3414 my $mr = $e->retrieve_metabib_metarecord($hold->target)
3415 or return $e->event;
3416 $tid = $mr->master_record;
3418 } elsif( $hold->hold_type eq OILS_HOLD_TYPE_TITLE ) {
3419 $tid = $hold->target;
3421 } elsif( $hold->hold_type eq OILS_HOLD_TYPE_VOLUME ) {
3422 $volume = $e->retrieve_asset_call_number($hold->target)
3423 or return $e->event;
3424 $tid = $volume->record;
3426 } elsif( $hold->hold_type eq OILS_HOLD_TYPE_ISSUANCE ) {
3427 $issuance = $e->retrieve_serial_issuance([
3429 {flesh => 1, flesh_fields => {siss => [ qw/subscription/ ]}}
3430 ]) or return $e->event;
3432 $tid = $issuance->subscription->record_entry;
3434 } elsif( $hold->hold_type eq OILS_HOLD_TYPE_MONOPART ) {
3435 $part = $e->retrieve_biblio_monograph_part([
3437 ]) or return $e->event;
3439 $tid = $part->record;
3441 } elsif( $hold->hold_type eq OILS_HOLD_TYPE_COPY || $hold->hold_type eq OILS_HOLD_TYPE_RECALL || $hold->hold_type eq OILS_HOLD_TYPE_FORCE ) {
3442 $copy = $e->retrieve_asset_copy([
3444 {flesh => 1, flesh_fields => {acp => ['call_number']}}
3445 ]) or return $e->event;
3447 $volume = $copy->call_number;
3448 $tid = $volume->record;
3451 if(!$copy and ref $hold->current_copy ) {
3452 $copy = $hold->current_copy;
3453 $hold->current_copy($copy->id);
3456 if(!$volume and $copy) {
3457 $volume = $e->retrieve_asset_call_number($copy->call_number);
3460 # TODO return metarcord mvr for M holds
3461 my $title = $e->retrieve_biblio_record_entry($tid);
3462 return ( ($no_mvr) ? undef : $U->record_to_mvr($title), $volume, $copy, $issuance, $part, $title );
3465 __PACKAGE__->register_method(
3466 method => 'clear_shelf_cache',
3467 api_name => 'open-ils.circ.hold.clear_shelf.get_cache',
3471 Returns the holds processed with the given cache key
3476 sub clear_shelf_cache {
3477 my($self, $client, $auth, $cache_key, $chunk_size) = @_;
3478 my $e = new_editor(authtoken => $auth, xact => 1);
3479 return $e->die_event unless $e->checkauth and $e->allowed('VIEW_HOLD');
3482 my $hold_data = OpenSRF::Utils::Cache->new('global')->get_cache($cache_key);
3485 $logger->info("no hold data found in cache"); # XXX TODO return event
3491 foreach (keys %$hold_data) {
3492 $maximum += scalar(@{ $hold_data->{$_} });
3494 $client->respond({"maximum" => $maximum, "progress" => 0});
3496 for my $action (sort keys %$hold_data) {
3497 while (@{$hold_data->{$action}}) {
3498 my @hid_chunk = splice @{$hold_data->{$action}}, 0, $chunk_size;
3500 my $result_chunk = $e->json_query({
3502 "acp" => ["barcode"],
3504 first_given_name second_given_name family_name alias
3507 "acnp" => [{column => "label", alias => "prefix"}],
3508 "acns" => [{column => "label", alias => "suffix"}],
3516 "field" => "id", "fkey" => "current_copy",
3519 "field" => "id", "fkey" => "call_number",
3522 "field" => "id", "fkey" => "record"
3525 "field" => "id", "fkey" => "prefix"
3528 "field" => "id", "fkey" => "suffix"
3532 "acpl" => {"field" => "id", "fkey" => "location"}
3535 "au" => {"field" => "id", "fkey" => "usr"}
3538 "where" => {"+ahr" => {"id" => \@hid_chunk}}
3539 }, {"substream" => 1}) or return $e->die_event;
3543 +{"action" => $action, "hold_details" => $_}
3554 __PACKAGE__->register_method(
3555 method => 'clear_shelf_process',
3557 api_name => 'open-ils.circ.hold.clear_shelf.process',
3560 1. Find all holds that have expired on the holds shelf
3562 3. If a clear-shelf status is configured, put targeted copies into this status
3563 4. Divide copies into 3 groups: items to transit, items to reshelve, and items
3564 that are needed for holds. No subsequent action is taken on the holds
3565 or items after grouping.
3570 sub clear_shelf_process {
3571 my($self, $client, $auth, $org_id, $match_copy) = @_;
3573 my $e = new_editor(authtoken=>$auth);
3574 $e->checkauth or return $e->die_event;
3575 my $cache = OpenSRF::Utils::Cache->new('global');
3577 $org_id ||= $e->requestor->ws_ou;
3578 $e->allowed('UPDATE_HOLD', $org_id) or return $e->die_event;
3580 my $copy_status = $U->ou_ancestor_setting_value($org_id, 'circ.holds.clear_shelf.copy_status');
3582 my @hold_ids = $self->method_lookup(
3583 "open-ils.circ.captured_holds.id_list.expired_on_shelf.retrieve"
3584 )->run($auth, $org_id, $match_copy);
3589 my @canceled_holds; # newly canceled holds
3590 my $chunk_size = 25; # chunked status updates
3592 for my $hold_id (@hold_ids) {
3594 $logger->info("Clear shelf processing hold $hold_id");
3596 my $hold = $e->retrieve_action_hold_request([
3599 flesh_fields => {ahr => ['current_copy']}
3603 if (!$hold->cancel_time) { # may be canceled but still on the holds shelf
3604 $hold->cancel_time('now');
3605 $hold->cancel_cause(2); # Hold Shelf expiration
3606 $e->update_action_hold_request($hold) or return $e->die_event;
3607 delete_hold_copy_maps($self, $e, $hold->id) and return $e->die_event;
3608 push(@canceled_holds, $hold_id);
3611 my $copy = $hold->current_copy;
3613 if($copy_status or $copy_status == 0) {
3614 # if a clear-shelf copy status is defined, update the copy
3615 $copy->status($copy_status);
3616 $copy->edit_date('now');
3617 $copy->editor($e->requestor->id);
3618 $e->update_asset_copy($copy) or return $e->die_event;
3621 push(@holds, $hold);
3622 $client->respond({maximum => scalar(@holds), progress => $counter}) if ( (++$counter % $chunk_size) == 0);
3631 pl_changed => pickup_lib_changed_on_shelf_holds($e, $org_id, \@hold_ids)
3634 for my $hold (@holds) {
3636 my $copy = $hold->current_copy;
3637 my ($alt_hold) = __PACKAGE__->find_nearest_permitted_hold($e, $copy, $e->requestor, 1);
3639 if($alt_hold and !$match_copy) {
3641 push(@{$cache_data{hold}}, $hold->id); # copy is needed for a hold
3643 } elsif($copy->circ_lib != $e->requestor->ws_ou) {
3645 push(@{$cache_data{transit}}, $hold->id); # copy needs to transit
3649 push(@{$cache_data{shelf}}, $hold->id); # copy needs to go back to the shelf
3653 my $cache_key = md5_hex(time . $$ . rand());
3654 $logger->info("clear_shelf_cache: storing under $cache_key");
3655 $cache->put_cache($cache_key, \%cache_data, 7200); # TODO: 2 hours. configurable?
3657 # tell the client we're done
3658 $client->respond_complete({cache_key => $cache_key});
3661 # fire off the hold cancelation trigger and wait for response so don't flood the service
3663 # refetch the holds to pick up the caclulated cancel_time,
3664 # which may be needed by Action/Trigger
3666 my $updated_holds = [];
3667 $updated_holds = $e->search_action_hold_request({id => \@canceled_holds}, {substream => 1}) if (@canceled_holds > 0);
3670 $U->create_events_for_hook(
3671 'hold_request.cancel.expire_holds_shelf',
3672 $_, $org_id, undef, undef, 1) for @$updated_holds;
3675 # tell the client we're done
3676 $client->respond_complete;
3680 # returns IDs for holds that are on the holds shelf but
3681 # have had their pickup_libs change while on the shelf.
3682 sub pickup_lib_changed_on_shelf_holds {
3685 my $ignore_holds = shift;
3686 $ignore_holds = [$ignore_holds] if !ref($ignore_holds);
3689 select => { alhr => ['id'] },
3694 fkey => 'current_copy'
3699 '+acp' => { status => OILS_COPY_STATUS_ON_HOLDS_SHELF },
3701 capture_time => { "!=" => undef },
3702 fulfillment_time => undef,
3703 current_shelf_lib => $org_id,
3704 pickup_lib => {'!=' => {'+alhr' => 'current_shelf_lib'}}
3709 $query->{where}->{'+alhr'}->{id} =
3710 {'not in' => $ignore_holds} if @$ignore_holds;
3712 my $hold_ids = $e->json_query($query);
3713 return [ map { $_->{id} } @$hold_ids ];
3716 __PACKAGE__->register_method(
3717 method => 'usr_hold_summary',
3718 api_name => 'open-ils.circ.holds.user_summary',
3720 Returns a summary of holds statuses for a given user
3724 sub usr_hold_summary {
3725 my($self, $conn, $auth, $user_id) = @_;
3727 my $e = new_editor(authtoken=>$auth);
3728 $e->checkauth or return $e->event;
3729 $e->allowed('VIEW_HOLD') or return $e->event;
3731 my $holds = $e->search_action_hold_request(
3734 fulfillment_time => undef,
3735 cancel_time => undef,
3739 my %summary = (1 => 0, 2 => 0, 3 => 0, 4 => 0);
3740 $summary{_hold_status($e, $_)} += 1 for @$holds;
3746 __PACKAGE__->register_method(
3747 method => 'hold_has_copy_at',
3748 api_name => 'open-ils.circ.hold.has_copy_at',
3751 'Returns the ID of the found copy and name of the shelving location if there is ' .
3752 'an available copy at the specified org unit. Returns empty hash otherwise. ' .
3753 'The anticipated use for this method is to determine whether an item is ' .
3754 'available at the library where the user is placing the hold (or, alternatively, '.
3755 'at the pickup library) to encourage bypassing the hold placement and just ' .
3756 'checking out the item.' ,
3758 { desc => 'Authentication Token', type => 'string' },
3759 { desc => 'Method Arguments. Options include: hold_type, hold_target, org_unit. '
3760 . 'hold_type is the hold type code (T, V, C, M, ...). '
3761 . 'hold_target is the identifier of the hold target object. '
3762 . 'org_unit is org unit ID.',
3767 desc => q/Result hash like { "copy" : copy_id, "location" : location_name }, empty hash on misses, event on error./,
3773 sub hold_has_copy_at {
3774 my($self, $conn, $auth, $args) = @_;
3776 my $e = new_editor(authtoken=>$auth);
3777 $e->checkauth or return $e->event;
3779 my $hold_type = $$args{hold_type};
3780 my $hold_target = $$args{hold_target};
3781 my $org_unit = $$args{org_unit};
3784 select => {acp => ['id'], acpl => ['name']},
3787 acpl => {field => 'id', filter => { holdable => 't'}, fkey => 'location'},
3788 ccs => {field => 'id', filter => { holdable => 't'}, fkey => 'status' }
3791 where => {'+acp' => { circulate => 't', deleted => 'f', holdable => 't', circ_lib => $org_unit, status => [0,7]}},
3795 if($hold_type eq 'C' or $hold_type eq 'F' or $hold_type eq 'R') {
3797 $query->{where}->{'+acp'}->{id} = $hold_target;
3799 } elsif($hold_type eq 'V') {
3801 $query->{where}->{'+acp'}->{call_number} = $hold_target;
3803 } elsif($hold_type eq 'P') {
3805 $query->{from}->{acp}->{acpm} = {
3806 field => 'target_copy',
3808 filter => {part => $hold_target},
3811 } elsif($hold_type eq 'I') {
3813 $query->{from}->{acp}->{sitem} = {
3816 filter => {issuance => $hold_target},
3819 } elsif($hold_type eq 'T') {
3821 $query->{from}->{acp}->{acn} = {
3823 fkey => 'call_number',
3827 filter => {id => $hold_target},
3835 $query->{from}->{acp}->{acn} = {
3837 fkey => 'call_number',
3846 filter => {metarecord => $hold_target},
3854 my $res = $e->json_query($query)->[0] or return {};
3855 return {copy => $res->{id}, location => $res->{name}} if $res;
3859 # returns true if the user already has an item checked out
3860 # that could be used to fulfill the requested hold.
3861 sub hold_item_is_checked_out {
3862 my($e, $user_id, $hold_type, $hold_target) = @_;
3865 select => {acp => ['id']},
3866 from => {acp => {}},
3870 in => { # copies for circs the user has checked out
3871 select => {circ => ['target_copy']},
3875 checkin_time => undef,
3877 {stop_fines => ["MAXFINES","LONGOVERDUE"]},
3878 {stop_fines => undef}
3888 if($hold_type eq 'C' || $hold_type eq 'R' || $hold_type eq 'F') {
3890 $query->{where}->{'+acp'}->{id}->{in}->{where}->{'target_copy'} = $hold_target;
3892 } elsif($hold_type eq 'V') {
3894 $query->{where}->{'+acp'}->{call_number} = $hold_target;
3896 } elsif($hold_type eq 'P') {
3898 $query->{from}->{acp}->{acpm} = {
3899 field => 'target_copy',
3901 filter => {part => $hold_target},
3904 } elsif($hold_type eq 'I') {
3906 $query->{from}->{acp}->{sitem} = {
3909 filter => {issuance => $hold_target},
3912 } elsif($hold_type eq 'T') {
3914 $query->{from}->{acp}->{acn} = {
3916 fkey => 'call_number',
3920 filter => {id => $hold_target},
3928 $query->{from}->{acp}->{acn} = {
3930 fkey => 'call_number',
3939 filter => {metarecord => $hold_target},
3947 return $e->json_query($query)->[0];
3950 __PACKAGE__->register_method(
3951 method => 'change_hold_title',
3952 api_name => 'open-ils.circ.hold.change_title',
3955 Updates all title level holds targeting the specified bibs to point a new bib./,
3957 { desc => 'Authentication Token', type => 'string' },
3958 { desc => 'New Target Bib Id', type => 'number' },
3959 { desc => 'Old Target Bib Ids', type => 'array' },
3961 return => { desc => '1 on success' }
3965 __PACKAGE__->register_method(
3966 method => 'change_hold_title_for_specific_holds',
3967 api_name => 'open-ils.circ.hold.change_title.specific_holds',
3970 Updates specified holds to target new bib./,
3972 { desc => 'Authentication Token', type => 'string' },
3973 { desc => 'New Target Bib Id', type => 'number' },
3974 { desc => 'Holds Ids for holds to update', type => 'array' },
3976 return => { desc => '1 on success' }
3981 sub change_hold_title {
3982 my( $self, $client, $auth, $new_bib_id, $bib_ids ) = @_;
3984 my $e = new_editor(authtoken=>$auth, xact=>1);
3985 return $e->die_event unless $e->checkauth;
3987 my $holds = $e->search_action_hold_request(
3990 cancel_time => undef,
3991 fulfillment_time => undef,
3997 flesh_fields => { ahr => ['usr'] }
4003 for my $hold (@$holds) {
4004 $e->allowed('UPDATE_HOLD', $hold->usr->home_ou) or return $e->die_event;
4005 $logger->info("Changing hold " . $hold->id . " target from " . $hold->target . " to $new_bib_id in title hold target change");
4006 $hold->target( $new_bib_id );
4007 $e->update_action_hold_request($hold) or return $e->die_event;
4012 _reset_hold($self, $e->requestor, $_) for @$holds;
4017 sub change_hold_title_for_specific_holds {
4018 my( $self, $client, $auth, $new_bib_id, $hold_ids ) = @_;
4020 my $e = new_editor(authtoken=>$auth, xact=>1);
4021 return $e->die_event unless $e->checkauth;
4023 my $holds = $e->search_action_hold_request(
4026 cancel_time => undef,
4027 fulfillment_time => undef,
4033 flesh_fields => { ahr => ['usr'] }
4039 for my $hold (@$holds) {
4040 $e->allowed('UPDATE_HOLD', $hold->usr->home_ou) or return $e->die_event;
4041 $logger->info("Changing hold " . $hold->id . " target from " . $hold->target . " to $new_bib_id in title hold target change");
4042 $hold->target( $new_bib_id );
4043 $e->update_action_hold_request($hold) or return $e->die_event;
4048 _reset_hold($self, $e->requestor, $_) for @$holds;
4053 __PACKAGE__->register_method(
4054 method => 'rec_hold_count',
4055 api_name => 'open-ils.circ.bre.holds.count',
4057 desc => q/Returns the total number of holds that target the
4058 selected bib record or its associated copies and call_numbers/,
4060 { desc => 'Bib ID', type => 'number' },
4061 { desc => q/Optional arguments. Supported arguments include:
4062 "pickup_lib_descendant" -> limit holds to those whose pickup
4063 library is equal to or is a child of the provided org unit/,
4067 return => {desc => 'Hold count', type => 'number'}
4071 __PACKAGE__->register_method(
4072 method => 'rec_hold_count',
4073 api_name => 'open-ils.circ.mmr.holds.count',
4075 desc => q/Returns the total number of holds that target the
4076 selected metarecord or its associated copies, call_numbers, and bib records/,
4078 { desc => 'Metarecord ID', type => 'number' },
4080 return => {desc => 'Hold count', type => 'number'}
4084 # XXX Need to add type I (and, soon, type P) holds to these counts
4085 sub rec_hold_count {
4086 my($self, $conn, $target_id, $args) = @_;
4093 filter => {metarecord => $target_id}
4100 filter => { id => $target_id },
4105 if($self->api_name =~ /mmr/) {
4106 delete $bre_join->{bre}->{filter};
4107 $bre_join->{bre}->{join} = $mmr_join;
4113 fkey => 'call_number',
4119 select => {ahr => [{column => 'id', transform => 'count', alias => 'count'}]},
4123 cancel_time => undef,
4124 fulfillment_time => undef,
4128 hold_type => [qw/C F R/],
4131 select => {acp => ['id']},
4132 from => { acp => $cn_join }
4142 select => {acn => ['id']},
4143 from => {acn => $bre_join}
4151 target => $target_id
4159 if($self->api_name =~ /mmr/) {
4160 $query->{where}->{'+ahr'}->{'-or'}->[2] = {
4165 select => {bre => ['id']},
4166 from => {bre => $mmr_join}
4172 $query->{where}->{'+ahr'}->{'-or'}->[3] = {
4175 target => $target_id
4181 if (my $pld = $args->{pickup_lib_descendant}) {
4183 my $top_ou = new_editor()->search_actor_org_unit(
4184 {parent_ou => undef}
4185 )->[0]; # XXX Assumes single root node. Not alone in this...
4187 $query->{where}->{'+ahr'}->{pickup_lib} = {
4189 select => {aou => [{
4191 transform => 'actor.org_unit_descendants',
4192 result_field => 'id'
4195 where => {id => $pld}
4197 } if ($pld != $top_ou->id);
4201 return new_editor()->json_query($query)->[0]->{count};
4204 # A helper function to calculate a hold's expiration time at a given
4205 # org_unit. Takes the org_unit as an argument and returns either the
4206 # hold expire time as an ISO8601 string or undef if there is no hold
4207 # expiration interval set for the subject ou.
4208 sub calculate_expire_time
4211 my $interval = $U->ou_ancestor_setting_value($ou, OILS_SETTING_HOLD_EXPIRE);
4213 my $date = DateTime->now->add(seconds => OpenSRF::Utils::interval_to_seconds($interval));
4214 return $U->epoch2ISO8601($date->epoch);
4220 __PACKAGE__->register_method(
4221 method => 'mr_hold_filter_attrs',
4222 api_name => 'open-ils.circ.mmr.holds.filters',
4227 Returns the set of available formats and languages for the
4228 constituent records of the provided metarcord.
4229 If an array of hold IDs is also provided, information about
4230 each is returned as well. This information includes:
4231 1. a slightly easier to read version of holdable_formats
4232 2. attributes describing the set of format icons included
4233 in the set of desired, constituent records.
4236 {desc => 'Metarecord ID', type => 'number'},
4237 {desc => 'Context Org ID', type => 'number'},
4238 {desc => 'Hold ID List', type => 'array'},
4242 Stream of objects. The first will have a 'metarecord' key
4243 containing non-hold-specific metarecord information, subsequent
4244 responses will contain a 'hold' key containing hold-specific
4252 sub mr_hold_filter_attrs {
4253 my ($self, $client, $mr_id, $org_id, $hold_ids) = @_;
4254 my $e = new_editor();
4256 # by default, return MR / hold attributes for all constituent
4257 # records with holdable copies. If there is a hard boundary,
4258 # though, limit to records with copies within the boundary,
4259 # since anything outside the boundary can never be held.
4262 $org_depth = $U->ou_ancestor_setting_value(
4263 $org_id, OILS_SETTING_HOLD_HARD_BOUNDARY) || 0;
4266 # get all org-scoped records w/ holdable copies for this metarecord
4267 my ($bre_ids) = $self->method_lookup(
4268 'open-ils.circ.holds.metarecord.filtered_records')->run(
4269 $mr_id, undef, $org_id, $org_depth);
4271 my $item_lang_attr = 'item_lang'; # configurable?
4272 my $format_attr = $e->retrieve_config_global_flag(
4273 'opac.metarecord.holds.format_attr')->value;
4275 # helper sub for fetching ccvms for a batch of record IDs
4276 sub get_batch_ccvms {
4277 my ($e, $attr, $bre_ids) = @_;
4278 return [] unless $bre_ids and @$bre_ids;
4279 my $vals = $e->search_metabib_record_attr_flat({
4283 return [] unless @$vals;
4284 return $e->search_config_coded_value_map({
4286 code => [map {$_->value} @$vals]
4290 my $langs = get_batch_ccvms($e, $item_lang_attr, $bre_ids);
4291 my $formats = get_batch_ccvms($e, $format_attr, $bre_ids);
4296 formats => $formats,
4301 return unless $hold_ids;
4302 my $icon_attr = $e->retrieve_config_global_flag('opac.icon_attr');
4303 $icon_attr = $icon_attr ? $icon_attr->value : '';
4305 for my $hold_id (@$hold_ids) {
4306 my $hold = $e->retrieve_action_hold_request($hold_id)
4307 or return $e->event;
4309 next unless $hold->hold_type eq 'M';
4319 # collect the ccvm's for the selected formats / language
4320 # (i.e. the holdable formats) on the MR.
4321 # this assumes a two-key structure for format / language,
4322 # though no assumption is made about the keys themselves.
4323 my $hformats = OpenSRF::Utils::JSON->JSON2perl($hold->holdable_formats);
4325 my $format_vals = [];
4326 for my $val (values %$hformats) {
4327 # val is either a single ccvm or an array of them
4328 $val = [$val] unless ref $val eq 'ARRAY';
4329 for my $node (@$val) {
4330 push (@$lang_vals, $node->{_val})
4331 if $node->{_attr} eq $item_lang_attr;
4332 push (@$format_vals, $node->{_val})
4333 if $node->{_attr} eq $format_attr;
4337 # fetch the ccvm's for consistency with the {metarecord} blob
4338 $resp->{hold}{formats} = $e->search_config_coded_value_map({
4339 ctype => $format_attr, code => $format_vals});
4340 $resp->{hold}{langs} = $e->search_config_coded_value_map({
4341 ctype => $item_lang_attr, code => $lang_vals});
4343 # find all of the bib records within this metarcord whose
4344 # format / language match the holdable formats on the hold
4345 my ($bre_ids) = $self->method_lookup(
4346 'open-ils.circ.holds.metarecord.filtered_records')->run(
4347 $hold->target, $hold->holdable_formats,
4348 $hold->selection_ou, $hold->selection_depth);
4350 # now find all of the 'icon' attributes for the records
4351 $resp->{hold}{icons} = get_batch_ccvms($e, $icon_attr, $bre_ids);
4352 $client->respond($resp);