1 package OpenILS::Application::Circ::Circulate;
2 use strict; use warnings;
3 use base 'OpenILS::Application';
4 use OpenSRF::EX qw(:try);
5 use OpenSRF::AppSession;
6 use OpenSRF::Utils::SettingsClient;
7 use OpenSRF::Utils::Logger qw(:logger);
8 use OpenILS::Const qw/:const/;
9 use OpenILS::Application::AppUtils;
11 my $U = "OpenILS::Application::AppUtils";
15 my $legacy_script_support = 0;
17 my $opac_renewal_use_circ_lib;
19 sub determine_booking_status {
20 unless (defined $booking_status) {
21 my $ses = create OpenSRF::AppSession("router");
22 $booking_status = grep {$_ eq "open-ils.booking"} @{
23 $ses->request("opensrf.router.info.class.list")->gather(1)
26 $logger->info("booking status: " . ($booking_status ? "on" : "off"));
29 return $booking_status;
35 flesh_fields => {acp => ['call_number','parts'], acn => ['record']}
41 my $conf = OpenSRF::Utils::SettingsClient->new;
42 my @pfx2 = ( "apps", "open-ils.circ","app_settings" );
44 $legacy_script_support = $conf->config_value(@pfx2, 'legacy_script_support');
45 $legacy_script_support = ($legacy_script_support and $legacy_script_support =~ /true/i);
47 my $lb = $conf->config_value( @pfx2, 'script_path' );
48 $lb = [ $lb ] unless ref($lb);
51 return unless $legacy_script_support;
53 my @pfx = ( @pfx2, "scripts" );
54 my $p = $conf->config_value( @pfx, 'circ_permit_patron' );
55 my $c = $conf->config_value( @pfx, 'circ_permit_copy' );
56 my $d = $conf->config_value( @pfx, 'circ_duration' );
57 my $f = $conf->config_value( @pfx, 'circ_recurring_fines' );
58 my $m = $conf->config_value( @pfx, 'circ_max_fines' );
59 my $pr = $conf->config_value( @pfx, 'circ_permit_renew' );
61 $logger->error( "Missing circ script(s)" )
62 unless( $p and $c and $d and $f and $m and $pr );
64 $scripts{circ_permit_patron} = $p;
65 $scripts{circ_permit_copy} = $c;
66 $scripts{circ_duration} = $d;
67 $scripts{circ_recurring_fines} = $f;
68 $scripts{circ_max_fines} = $m;
69 $scripts{circ_permit_renew} = $pr;
72 "circulator: Loaded rules scripts for circ: " .
73 "circ permit patron = $p, ".
74 "circ permit copy = $c, ".
75 "circ duration = $d, ".
76 "circ recurring fines = $f, " .
77 "circ max fines = $m, ".
78 "circ renew permit = $pr. ".
80 "legacy script support = ". ($legacy_script_support) ? 'yes' : 'no'
84 __PACKAGE__->register_method(
85 method => "run_method",
86 api_name => "open-ils.circ.checkout.permit",
88 Determines if the given checkout can occur
89 @param authtoken The login session key
90 @param params A trailing hash of named params including
91 barcode : The copy barcode,
92 patron : The patron the checkout is occurring for,
93 renew : true or false - whether or not this is a renewal
94 @return The event that occurred during the permit check.
98 __PACKAGE__->register_method (
99 method => 'run_method',
100 api_name => 'open-ils.circ.checkout.permit.override',
101 signature => q/@see open-ils.circ.checkout.permit/,
105 __PACKAGE__->register_method(
106 method => "run_method",
107 api_name => "open-ils.circ.checkout",
110 @param authtoken The login session key
111 @param params A named hash of params including:
113 barcode If no copy is provided, the copy is retrieved via barcode
114 copyid If no copy or barcode is provide, the copy id will be use
115 patron The patron's id
116 noncat True if this is a circulation for a non-cataloted item
117 noncat_type The non-cataloged type id
118 noncat_circ_lib The location for the noncat circ.
119 precat The item has yet to be cataloged
120 dummy_title The temporary title of the pre-cataloded item
121 dummy_author The temporary authr of the pre-cataloded item
122 Default is the home org of the staff member
123 @return The SUCCESS event on success, any other event depending on the error
126 __PACKAGE__->register_method(
127 method => "run_method",
128 api_name => "open-ils.circ.checkin",
131 Generic super-method for handling all copies
132 @param authtoken The login session key
133 @param params Hash of named parameters including:
134 barcode - The copy barcode
135 force - If true, copies in bad statuses will be checked in and give good statuses
136 noop - don't capture holds or put items into transit
137 void_overdues - void all overdues for the circulation (aka amnesty)
142 __PACKAGE__->register_method(
143 method => "run_method",
144 api_name => "open-ils.circ.checkin.override",
145 signature => q/@see open-ils.circ.checkin/
148 __PACKAGE__->register_method(
149 method => "run_method",
150 api_name => "open-ils.circ.renew.override",
151 signature => q/@see open-ils.circ.renew/,
155 __PACKAGE__->register_method(
156 method => "run_method",
157 api_name => "open-ils.circ.renew",
158 notes => <<" NOTES");
159 PARAMS( authtoken, circ => circ_id );
160 open-ils.circ.renew(login_session, circ_object);
161 Renews the provided circulation. login_session is the requestor of the
162 renewal and if the logged in user is not the same as circ->usr, then
163 the logged in user must have RENEW_CIRC permissions.
166 __PACKAGE__->register_method(
167 method => "run_method",
168 api_name => "open-ils.circ.checkout.full"
170 __PACKAGE__->register_method(
171 method => "run_method",
172 api_name => "open-ils.circ.checkout.full.override"
174 __PACKAGE__->register_method(
175 method => "run_method",
176 api_name => "open-ils.circ.reservation.pickup"
178 __PACKAGE__->register_method(
179 method => "run_method",
180 api_name => "open-ils.circ.reservation.return"
182 __PACKAGE__->register_method(
183 method => "run_method",
184 api_name => "open-ils.circ.reservation.return.override"
186 __PACKAGE__->register_method(
187 method => "run_method",
188 api_name => "open-ils.circ.checkout.inspect",
189 desc => q/Returns the circ matrix test result and, on success, the rule set and matrix test object/
194 my( $self, $conn, $auth, $args ) = @_;
195 translate_legacy_args($args);
196 my $api = $self->api_name;
199 OpenILS::Application::Circ::Circulator->new($auth, %$args);
201 return circ_events($circulator) if $circulator->bail_out;
203 $circulator->use_booking(determine_booking_status());
205 # --------------------------------------------------------------------------
206 # First, check for a booking transit, as the barcode may not be a copy
207 # barcode, but a resource barcode, and nothing else in here will work
208 # --------------------------------------------------------------------------
210 if ($circulator->use_booking && (my $bc = $circulator->copy_barcode) && $api !~ /checkout|inspect/) { # do we have a barcode?
211 my $resources = $circulator->editor->search_booking_resource( { barcode => $bc } ); # any resources by this barcode?
212 if (@$resources) { # yes!
214 my $res_id_list = [ map { $_->id } @$resources ];
215 my $transit = $circulator->editor->search_action_reservation_transit_copy(
217 { target_copy => $res_id_list, dest => $circulator->circ_lib, dest_recv_time => undef },
218 { order_by => { artc => 'source_send_time' }, limit => 1 }
220 )->[0]; # Any transit for this barcode?
222 if ($transit) { # yes! unwrap it.
224 my $reservation = $circulator->editor->retrieve_booking_reservation( $transit->reservation );
225 my $res_type = $circulator->editor->retrieve_booking_resource_type( $reservation->target_resource_type );
227 my $success_event = new OpenILS::Event(
228 "SUCCESS", "payload" => {"reservation" => $reservation}
230 if ($U->is_true($res_type->catalog_item)) { # is there a copy to be had here?
231 if (my $copy = $circulator->editor->search_asset_copy([
232 { barcode => $bc, deleted => 'f' }, $MK_ENV_FLESH
233 ])->[0]) { # got a copy
234 $copy->status( $transit->copy_status );
235 $copy->editor($circulator->editor->requestor->id);
236 $copy->edit_date('now');
237 $circulator->editor->update_asset_copy($copy);
238 $success_event->{"payload"}->{"record"} =
239 $U->record_to_mvr($copy->call_number->record);
240 $success_event->{"payload"}->{"volume"} = $copy->call_number;
241 $copy->call_number($copy->call_number->id);
242 $success_event->{"payload"}->{"copy"} = $copy;
246 $transit->dest_recv_time('now');
247 $circulator->editor->update_action_reservation_transit_copy( $transit );
249 $circulator->editor->commit;
250 # Formerly this branch just stopped here. Argh!
251 $conn->respond_complete($success_event);
259 # --------------------------------------------------------------------------
260 # Go ahead and load the script runner to make sure we have all
261 # of the objects we need
262 # --------------------------------------------------------------------------
264 if ($circulator->use_booking) {
265 $circulator->is_res_checkin($circulator->is_checkin(1))
266 if $api =~ /reservation.return/ or (
267 $api =~ /checkin/ and $circulator->seems_like_reservation()
270 $circulator->is_res_checkout(1) if $api =~ /reservation.pickup/;
273 $circulator->is_renewal(1) if $api =~ /renew/;
274 $circulator->is_checkin(1) if $api =~ /checkin/;
276 $circulator->mk_env();
277 $circulator->noop(1) if $circulator->claims_never_checked_out;
279 if($legacy_script_support and not $circulator->is_checkin) {
280 $circulator->mk_script_runner();
281 $circulator->legacy_script_support(1);
282 $circulator->circ_permit_patron($scripts{circ_permit_patron});
283 $circulator->circ_permit_copy($scripts{circ_permit_copy});
284 $circulator->circ_duration($scripts{circ_duration});
285 $circulator->circ_permit_renew($scripts{circ_permit_renew});
287 return circ_events($circulator) if $circulator->bail_out;
290 $circulator->override(1) if $api =~ /override/o;
292 if( $api =~ /checkout\.permit/ ) {
293 $circulator->do_permit();
295 } elsif( $api =~ /checkout.full/ ) {
297 # requesting a precat checkout implies that any required
298 # overrides have been performed. Go ahead and re-override.
299 $circulator->skip_permit_key(1);
300 $circulator->override(1) if $circulator->request_precat;
301 $circulator->do_permit();
302 $circulator->is_checkout(1);
303 unless( $circulator->bail_out ) {
304 $circulator->events([]);
305 $circulator->do_checkout();
308 } elsif( $circulator->is_res_checkout ) {
309 $circulator->do_reservation_pickup();
311 } elsif( $api =~ /inspect/ ) {
312 my $data = $circulator->do_inspect();
313 $circulator->editor->rollback;
316 } elsif( $api =~ /checkout/ ) {
317 $circulator->is_checkout(1);
318 $circulator->do_checkout();
320 } elsif( $circulator->is_res_checkin ) {
321 $circulator->do_reservation_return();
322 $circulator->do_checkin() if ($circulator->copy());
323 } elsif( $api =~ /checkin/ ) {
324 $circulator->do_checkin();
326 } elsif( $api =~ /renew/ ) {
327 $circulator->is_renewal(1);
328 $circulator->do_renew();
331 if( $circulator->bail_out ) {
334 # make sure no success event accidentally slip in
336 [ grep { $_->{textcode} ne 'SUCCESS' } @{$circulator->events} ]);
339 my @e = @{$circulator->events};
340 push( @ee, $_->{textcode} ) for @e;
341 $logger->info("circulator: bailing out with events: " . (join ", ", @ee));
343 $circulator->editor->rollback;
347 $circulator->editor->commit;
349 if ($circulator->generate_lost_overdue) {
350 # Generating additional overdue billings has to happen after the
351 # main commit and before the final respond() so the caller can
352 # receive the latest transaction summary.
353 my $evt = $circulator->generate_lost_overdue_fines;
354 $circulator->bail_on_events($evt) if $evt;
358 $conn->respond_complete(circ_events($circulator));
360 $circulator->script_runner->cleanup if $circulator->script_runner;
362 return undef if $circulator->bail_out;
364 $circulator->do_hold_notify($circulator->notify_hold)
365 if $circulator->notify_hold;
366 $circulator->retarget_holds if $circulator->retarget;
367 $circulator->append_reading_list;
368 $circulator->make_trigger_events;
375 my @e = @{$circ->events};
376 # if we have multiple events, SUCCESS should not be one of them;
377 @e = grep { $_->{textcode} ne 'SUCCESS' } @e if @e > 1;
378 return (@e == 1) ? $e[0] : \@e;
382 sub translate_legacy_args {
385 if( $$args{barcode} ) {
386 $$args{copy_barcode} = $$args{barcode};
387 delete $$args{barcode};
390 if( $$args{copyid} ) {
391 $$args{copy_id} = $$args{copyid};
392 delete $$args{copyid};
395 if( $$args{patronid} ) {
396 $$args{patron_id} = $$args{patronid};
397 delete $$args{patronid};
400 if( $$args{patron} and !ref($$args{patron}) ) {
401 $$args{patron_id} = $$args{patron};
402 delete $$args{patron};
406 if( $$args{noncat} ) {
407 $$args{is_noncat} = $$args{noncat};
408 delete $$args{noncat};
411 if( $$args{precat} ) {
412 $$args{is_precat} = $$args{request_precat} = $$args{precat};
413 delete $$args{precat};
419 # --------------------------------------------------------------------------
420 # This package actually manages all of the circulation logic
421 # --------------------------------------------------------------------------
422 package OpenILS::Application::Circ::Circulator;
423 use strict; use warnings;
424 use vars q/$AUTOLOAD/;
426 use OpenILS::Utils::Fieldmapper;
427 use OpenSRF::Utils::Cache;
428 use Digest::MD5 qw(md5_hex);
429 use DateTime::Format::ISO8601;
430 use OpenILS::Utils::PermitHold;
431 use OpenSRF::Utils qw/:datetime/;
432 use OpenSRF::Utils::SettingsClient;
433 use OpenILS::Application::Circ::Holds;
434 use OpenILS::Application::Circ::Transit;
435 use OpenSRF::Utils::Logger qw(:logger);
436 use OpenILS::Utils::CStoreEditor qw/:funcs/;
437 use OpenILS::Application::Circ::ScriptBuilder;
438 use OpenILS::Const qw/:const/;
439 use OpenILS::Utils::Penalty;
440 use OpenILS::Application::Circ::CircCommon;
443 my $holdcode = "OpenILS::Application::Circ::Holds";
444 my $transcode = "OpenILS::Application::Circ::Transit";
450 # --------------------------------------------------------------------------
451 # Add a pile of automagic getter/setter methods
452 # --------------------------------------------------------------------------
453 my @AUTOLOAD_FIELDS = qw/
500 recurring_fines_level
513 cancelled_hold_transit
520 circ_matrix_matchpoint
522 legacy_script_support
532 claims_never_checked_out
537 generate_lost_overdue
548 my $type = ref($self) or die "$self is not an object";
550 my $name = $AUTOLOAD;
553 unless (grep { $_ eq $name } @AUTOLOAD_FIELDS) {
554 $logger->error("circulator: $type: invalid autoload field: $name");
555 die "$type: invalid autoload field: $name\n"
560 *{"${type}::${name}"} = sub {
563 $s->{$name} = $v if defined $v;
567 return $self->$name($data);
572 my( $class, $auth, %args ) = @_;
573 $class = ref($class) || $class;
574 my $self = bless( {}, $class );
577 $self->editor(new_editor(xact => 1, authtoken => $auth));
579 unless( $self->editor->checkauth ) {
580 $self->bail_on_events($self->editor->event);
584 $self->cache_handle(OpenSRF::Utils::Cache->new('global'));
586 $self->$_($args{$_}) for keys %args;
589 ($self->circ_lib) ? $self->circ_lib : $self->editor->requestor->ws_ou);
591 # if this is a renewal, default to desk_renewal
592 $self->desk_renewal(1) unless
593 $self->opac_renewal or $self->phone_renewal or $self->sip_renewal;
595 $self->capture('') unless $self->capture;
597 unless(%user_groups) {
598 my $gps = $self->editor->retrieve_all_permission_grp_tree;
599 %user_groups = map { $_->id => $_ } @$gps;
606 # --------------------------------------------------------------------------
607 # True if we should discontinue processing
608 # --------------------------------------------------------------------------
610 my( $self, $bool ) = @_;
611 if( defined $bool ) {
612 $logger->info("circulator: BAILING OUT") if $bool;
613 $self->{bail_out} = $bool;
615 return $self->{bail_out};
620 my( $self, @evts ) = @_;
623 $e->{payload} = $self->copy if
624 ($e->{textcode} eq 'COPY_NOT_AVAILABLE');
626 $logger->info("circulator: pushing event ".$e->{textcode});
627 push( @{$self->events}, $e ) unless
628 grep { $_->{textcode} eq $e->{textcode} } @{$self->events};
634 return '' if $self->skip_permit_key;
635 my $key = md5_hex( time() . rand() . "$$" );
636 $self->cache_handle->put_cache( "oils_permit_key_$key", 1, 300 );
637 return $self->permit_key($key);
640 sub check_permit_key {
642 return 1 if $self->skip_permit_key;
643 my $key = $self->permit_key;
644 return 0 unless $key;
645 my $k = "oils_permit_key_$key";
646 my $one = $self->cache_handle->get_cache($k);
647 $self->cache_handle->delete_cache($k);
648 return ($one) ? 1 : 0;
651 sub seems_like_reservation {
654 # Some words about the following method:
655 # 1) It requires the VIEW_USER permission, but that's not an
656 # issue, right, since all staff should have that?
657 # 2) It returns only one reservation at a time, even if an item can be
658 # and is currently overbooked. Hmmm....
659 my $booking_ses = create OpenSRF::AppSession("open-ils.booking");
660 my $result = $booking_ses->request(
661 "open-ils.booking.reservations.by_returnable_resource_barcode",
662 $self->editor->authtoken,
665 $booking_ses->disconnect;
667 return $self->bail_on_events($result) if defined $U->event_code($result);
670 $self->reservation(shift @$result);
678 # save_trimmed_copy() used just to be a block in mk_env(), but was separated for re-use
679 sub save_trimmed_copy {
680 my ($self, $copy) = @_;
683 $self->volume($copy->call_number);
684 $self->title($self->volume->record);
685 $self->copy->call_number($self->volume->id);
686 $self->volume->record($self->title->id);
687 $self->is_precat(1) if $self->volume->id == OILS_PRECAT_CALL_NUMBER;
688 if($self->copy->deposit_amount and $self->copy->deposit_amount > 0) {
689 $self->is_deposit(1) if $U->is_true($self->copy->deposit);
690 $self->is_rental(1) unless $U->is_true($self->copy->deposit);
696 my $e = $self->editor;
698 # --------------------------------------------------------------------------
699 # Grab the fleshed copy
700 # --------------------------------------------------------------------------
701 unless($self->is_noncat) {
704 $copy = $e->retrieve_asset_copy(
705 [$self->copy_id, $MK_ENV_FLESH ]) or return $e->event;
707 } elsif( $self->copy_barcode ) {
709 $copy = $e->search_asset_copy(
710 [{barcode => $self->copy_barcode, deleted => 'f'}, $MK_ENV_FLESH ])->[0];
711 } elsif( $self->reservation ) {
712 my $res = $e->json_query(
714 "select" => {"acp" => ["id"]},
719 "field" => "barcode",
723 "field" => "current_resource"
731 "id" => (ref $self->reservation) ?
732 $self->reservation->id : $self->reservation
737 if (ref $res eq "ARRAY" and scalar @$res) {
738 $logger->info("circulator: mapped reservation " .
739 $self->reservation . " to copy " . $res->[0]->{"id"});
740 $copy = $e->retrieve_asset_copy([$res->[0]->{"id"}, $MK_ENV_FLESH]);
745 $self->save_trimmed_copy($copy);
747 # We can't renew if there is no copy
748 return $self->bail_on_events(OpenILS::Event->new('ASSET_COPY_NOT_FOUND'))
749 if $self->is_renewal;
754 # --------------------------------------------------------------------------
756 # --------------------------------------------------------------------------
760 flesh_fields => {au => [ qw/ card / ]}
763 if( $self->patron_id ) {
764 $patron = $e->retrieve_actor_user([$self->patron_id, $flesh])
765 or return $self->bail_on_events(OpenILS::Event->new('ACTOR_USER_NOT_FOUND'));
767 } elsif( $self->patron_barcode ) {
769 # note: throwing ACTOR_USER_NOT_FOUND instead of ACTOR_CARD_NOT_FOUND is intentional
770 my $card = $e->search_actor_card({barcode => $self->patron_barcode})->[0]
771 or return $self->bail_on_events(OpenILS::Event->new('ACTOR_USER_NOT_FOUND'));
773 $patron = $e->retrieve_actor_user($card->usr)
774 or return $self->bail_on_events(OpenILS::Event->new('ACTOR_USER_NOT_FOUND'));
776 # Use the card we looked up, not the patron's primary, for card active checks
777 $patron->card($card);
780 if( my $copy = $self->copy ) {
783 $flesh->{flesh_fields}->{circ} = ['usr'];
785 my $circ = $e->search_action_circulation([
786 {target_copy => $copy->id, checkin_time => undef}, $flesh
790 $patron = $circ->usr;
791 $circ->usr($patron->id); # de-flesh for consistency
797 return $self->bail_on_events(OpenILS::Event->new('ACTOR_USER_NOT_FOUND'))
798 unless $self->patron($patron) or $self->is_checkin;
800 unless($self->is_checkin) {
802 # Check for inactivity and patron reg. expiration
804 $self->bail_on_events(OpenILS::Event->new('PATRON_INACTIVE'))
805 unless $U->is_true($patron->active);
807 $self->bail_on_events(OpenILS::Event->new('PATRON_CARD_INACTIVE'))
808 unless $U->is_true($patron->card->active);
810 my $expire = DateTime::Format::ISO8601->new->parse_datetime(
811 cleanse_ISO8601($patron->expire_date));
813 $self->bail_on_events(OpenILS::Event->new('PATRON_ACCOUNT_EXPIRED'))
814 if( CORE::time > $expire->epoch ) ;
818 # --------------------------------------------------------------------------
819 # This builds the script runner environment and fetches most of the
821 # --------------------------------------------------------------------------
822 sub mk_script_runner {
828 qw/copy copy_barcode copy_id patron
829 patron_id patron_barcode volume title editor/;
831 # Translate our objects into the ScriptBuilder args hash
832 $$args{$_} = $self->$_() for @fields;
834 $args->{ignore_user_status} = 1 if $self->is_checkin;
835 $$args{fetch_patron_by_circ_copy} = 1;
836 $$args{fetch_patron_circ_info} = 1 unless $self->is_checkin;
838 if( my $pco = $self->pending_checkouts ) {
839 $logger->info("circulator: we were given a pending checkouts number of $pco");
840 $$args{patronItemsOut} = $pco;
843 # This fetches most of the objects we need
844 $self->script_runner(
845 OpenILS::Application::Circ::ScriptBuilder->build($args));
847 # Now we translate the ScriptBuilder objects back into self
848 $self->$_($$args{$_}) for @fields;
850 my @evts = @{$args->{_events}} if $args->{_events};
852 $logger->debug("circulator: script builder returned events: @evts") if @evts;
856 # Anything besides ASSET_COPY_NOT_FOUND will stop processing
857 if(!$self->is_noncat and
859 $evts[0]->{textcode} eq 'ASSET_COPY_NOT_FOUND') {
863 my @e = grep { $_->{textcode} ne 'ASSET_COPY_NOT_FOUND' } @evts;
864 return $self->bail_on_events(@e);
869 $self->is_precat(1) if $self->copy->call_number == OILS_PRECAT_CALL_NUMBER;
870 if($self->copy->deposit_amount and $self->copy->deposit_amount > 0) {
871 $self->is_deposit(1) if $U->is_true($self->copy->deposit);
872 $self->is_rental(1) unless $U->is_true($self->copy->deposit);
876 # We can't renew if there is no copy
877 return $self->bail_on_events(@evts) if
878 $self->is_renewal and !$self->copy;
880 # Set some circ-specific flags in the script environment
881 my $evt = "environment";
882 $self->script_runner->insert("$evt.isRenewal", ($self->is_renewal) ? 1 : undef);
884 if( $self->is_noncat ) {
885 $self->script_runner->insert("$evt.isNonCat", 1);
886 $self->script_runner->insert("$evt.nonCatType", $self->noncat_type);
889 if( $self->is_precat ) {
890 $self->script_runner->insert("environment.isPrecat", 1, 1);
893 $self->script_runner->add_path( $_ ) for @$script_libs;
898 # --------------------------------------------------------------------------
899 # Does the circ permit work
900 # --------------------------------------------------------------------------
904 $self->log_me("do_permit()");
906 unless( $self->editor->requestor->id == $self->patron->id ) {
907 return $self->bail_on_events($self->editor->event)
908 unless( $self->editor->allowed('VIEW_PERMIT_CHECKOUT') );
911 $self->check_captured_holds();
912 $self->do_copy_checks();
913 return if $self->bail_out;
914 $self->run_patron_permit_scripts();
915 $self->run_copy_permit_scripts()
916 unless $self->is_precat or $self->is_noncat;
917 $self->check_item_deposit_events();
918 $self->override_events();
919 return if $self->bail_out;
921 if($self->is_precat and not $self->request_precat) {
924 'ITEM_NOT_CATALOGED', payload => $self->mk_permit_key));
925 return $self->bail_out(1) unless $self->is_renewal;
929 OpenILS::Event->new('SUCCESS', payload => $self->mk_permit_key));
932 sub check_item_deposit_events {
934 $self->push_events(OpenILS::Event->new('ITEM_DEPOSIT_REQUIRED', payload => $self->copy))
935 if $self->is_deposit and not $self->is_deposit_exempt;
936 $self->push_events(OpenILS::Event->new('ITEM_RENTAL_FEE_REQUIRED', payload => $self->copy))
937 if $self->is_rental and not $self->is_rental_exempt;
940 # returns true if the user is not required to pay deposits
941 sub is_deposit_exempt {
943 my $pid = (ref $self->patron->profile) ?
944 $self->patron->profile->id : $self->patron->profile;
945 my $groups = $U->ou_ancestor_setting_value(
946 $self->circ_lib, 'circ.deposit.exempt_groups', $self->editor);
947 for my $grp (@$groups) {
948 return 1 if $self->is_group_descendant($grp, $pid);
953 # returns true if the user is not required to pay rental fees
954 sub is_rental_exempt {
956 my $pid = (ref $self->patron->profile) ?
957 $self->patron->profile->id : $self->patron->profile;
958 my $groups = $U->ou_ancestor_setting_value(
959 $self->circ_lib, 'circ.rental.exempt_groups', $self->editor);
960 for my $grp (@$groups) {
961 return 1 if $self->is_group_descendant($grp, $pid);
966 sub is_group_descendant {
967 my($self, $p_id, $c_id) = @_;
968 return 0 unless defined $p_id and defined $c_id;
969 return 1 if $c_id == $p_id;
970 while(my $grp = $user_groups{$c_id}) {
971 $c_id = $grp->parent;
972 return 0 unless defined $c_id;
973 return 1 if $c_id == $p_id;
978 sub check_captured_holds {
980 my $copy = $self->copy;
981 my $patron = $self->patron;
983 return undef unless $copy;
985 my $s = $U->copy_status($copy->status)->id;
986 return unless $s == OILS_COPY_STATUS_ON_HOLDS_SHELF;
987 $logger->info("circulator: copy is on holds shelf, searching for the correct hold");
989 # Item is on the holds shelf, make sure it's going to the right person
990 my $holds = $self->editor->search_action_hold_request(
993 current_copy => $copy->id ,
994 capture_time => { '!=' => undef },
995 cancel_time => undef,
996 fulfillment_time => undef
1002 if( $holds and $$holds[0] ) {
1003 return undef if $$holds[0]->usr == $patron->id;
1006 $logger->info("circulator: this copy is needed by a different patron to fulfill a hold");
1008 $self->push_events(OpenILS::Event->new('ITEM_ON_HOLDS_SHELF'));
1012 sub do_copy_checks {
1014 my $copy = $self->copy;
1015 return unless $copy;
1017 my $stat = $U->copy_status($copy->status)->id;
1019 # We cannot check out a copy if it is in-transit
1020 if( $stat == OILS_COPY_STATUS_IN_TRANSIT ) {
1021 return $self->bail_on_events(OpenILS::Event->new('COPY_IN_TRANSIT'));
1024 $self->handle_claims_returned();
1025 return if $self->bail_out;
1027 # no claims returned circ was found, check if there is any open circ
1028 unless( $self->is_renewal ) {
1030 my $circs = $self->editor->search_action_circulation(
1031 { target_copy => $copy->id, checkin_time => undef }
1034 if(my $old_circ = $circs->[0]) { # an open circ was found
1036 my $payload = {copy => $copy};
1038 if($old_circ->usr == $self->patron->id) {
1040 $payload->{old_circ} = $old_circ;
1042 # If there is an open circulation on the checkout item and an auto-renew
1043 # interval is defined, inform the caller that they should go
1044 # ahead and renew the item instead of warning about open circulations.
1046 my $auto_renew_intvl = $U->ou_ancestor_setting_value(
1048 'circ.checkout_auto_renew_age',
1052 if($auto_renew_intvl) {
1053 my $intvl_seconds = OpenSRF::Utils->interval_to_seconds($auto_renew_intvl);
1054 my $checkout_time = DateTime::Format::ISO8601->new->parse_datetime( cleanse_ISO8601($old_circ->xact_start) );
1056 if(DateTime->now > $checkout_time->add(seconds => $intvl_seconds)) {
1057 $payload->{auto_renew} = 1;
1062 return $self->bail_on_events(
1063 OpenILS::Event->new('OPEN_CIRCULATION_EXISTS', payload => $payload)
1069 my $LEGACY_CIRC_EVENT_MAP = {
1070 'no_item' => 'ITEM_NOT_CATALOGED',
1071 'actor.usr.barred' => 'PATRON_BARRED',
1072 'asset.copy.circulate' => 'COPY_CIRC_NOT_ALLOWED',
1073 'asset.copy.status' => 'COPY_NOT_AVAILABLE',
1074 'asset.copy_location.circulate' => 'COPY_CIRC_NOT_ALLOWED',
1075 'config.circ_matrix_test.circulate' => 'COPY_CIRC_NOT_ALLOWED',
1076 'config.circ_matrix_test.max_items_out' => 'PATRON_EXCEEDS_CHECKOUT_COUNT',
1077 'config.circ_matrix_test.max_overdue' => 'PATRON_EXCEEDS_OVERDUE_COUNT',
1078 'config.circ_matrix_test.max_fines' => 'PATRON_EXCEEDS_FINES',
1079 'config.circ_matrix_circ_mod_test' => 'PATRON_EXCEEDS_CHECKOUT_COUNT',
1083 # ---------------------------------------------------------------------
1084 # This pushes any patron-related events into the list but does not
1085 # set bail_out for any events
1086 # ---------------------------------------------------------------------
1087 sub run_patron_permit_scripts {
1089 my $runner = $self->script_runner;
1090 my $patronid = $self->patron->id;
1094 if(!$self->legacy_script_support) {
1096 my $results = $self->run_indb_circ_test;
1097 unless($self->circ_test_success) {
1098 # no_item result is OK during noncat checkout
1099 unless(@$results == 1 && $results->[0]->{fail_part} eq 'no_item' and $self->is_noncat) {
1100 push @allevents, $self->matrix_test_result_events;
1106 # ---------------------------------------------------------------------
1107 # # Now run the patron permit script
1108 # ---------------------------------------------------------------------
1109 $runner->load($self->circ_permit_patron);
1110 my $result = $runner->run or
1111 throw OpenSRF::EX::ERROR ("Circ Permit Patron Script Died: $@");
1113 my $patron_events = $result->{events};
1115 OpenILS::Utils::Penalty->calculate_penalties($self->editor, $self->patron->id, $self->circ_lib);
1116 my $mask = ($self->is_renewal) ? 'RENEW' : 'CIRC';
1117 my $penalties = OpenILS::Utils::Penalty->retrieve_penalties($self->editor, $patronid, $self->circ_lib, $mask);
1118 $penalties = $penalties->{fatal_penalties};
1120 for my $pen (@$penalties) {
1121 my $event = OpenILS::Event->new($pen->name);
1122 $event->{desc} = $pen->label;
1123 push(@allevents, $event);
1126 push(@allevents, OpenILS::Event->new($_)) for (@$patron_events);
1130 $_->{payload} = $self->copy if
1131 ($_->{textcode} eq 'COPY_NOT_AVAILABLE');
1134 $logger->info("circulator: permit_patron script returned events: @allevents") if @allevents;
1136 $self->push_events(@allevents);
1139 sub matrix_test_result_codes {
1141 map { $_->{"fail_part"} } @{$self->matrix_test_result};
1144 sub matrix_test_result_events {
1147 my $event = new OpenILS::Event(
1148 $LEGACY_CIRC_EVENT_MAP->{$_->{"fail_part"}} || $_->{"fail_part"}
1150 $event->{"payload"} = {"fail_part" => $_->{"fail_part"}};
1152 } (@{$self->matrix_test_result});
1155 sub run_indb_circ_test {
1157 return $self->matrix_test_result if $self->matrix_test_result;
1159 my $dbfunc = ($self->is_renewal) ?
1160 'action.item_user_renew_test' : 'action.item_user_circ_test';
1162 if( $self->is_precat && $self->request_precat) {
1163 $self->make_precat_copy;
1164 return if $self->bail_out;
1167 my $results = $self->editor->json_query(
1171 ($self->is_noncat or ($self->is_precat and !$self->override and !$self->is_renewal)) ? undef : $self->copy->id,
1177 $self->circ_test_success($U->is_true($results->[0]->{success}));
1179 if(my $mp = $results->[0]->{matchpoint}) {
1180 $logger->info("circulator: circ policy test found matchpoint built via rows " . $results->[0]->{buildrows});
1181 $self->circ_matrix_matchpoint($self->editor->retrieve_config_circ_matrix_matchpoint($mp));
1182 $self->circ_matrix_matchpoint->duration_rule($self->editor->retrieve_config_rules_circ_duration($results->[0]->{duration_rule}));
1183 if(defined($results->[0]->{renewals})) {
1184 $self->circ_matrix_matchpoint->duration_rule->max_renewals($results->[0]->{renewals});
1186 $self->circ_matrix_matchpoint->recurring_fine_rule($self->editor->retrieve_config_rules_recurring_fine($results->[0]->{recurring_fine_rule}));
1187 if(defined($results->[0]->{grace_period})) {
1188 $self->circ_matrix_matchpoint->recurring_fine_rule->grace_period($results->[0]->{grace_period});
1190 $self->circ_matrix_matchpoint->max_fine_rule($self->editor->retrieve_config_rules_max_fine($results->[0]->{max_fine_rule}));
1191 $self->circ_matrix_matchpoint->hard_due_date($self->editor->retrieve_config_hard_due_date($results->[0]->{hard_due_date}));
1192 # Grab the *last* response for limit_groups, where it is more likely to be filled
1193 $self->limit_groups($results->[-1]->{limit_groups});
1196 return $self->matrix_test_result($results);
1199 # ---------------------------------------------------------------------
1200 # given a use and copy, this will calculate the circulation policy
1201 # parameters. Only works with in-db circ.
1202 # ---------------------------------------------------------------------
1206 return OpenILS::Event->new('ASSET_COPY_NOT_FOUND') unless $self->copy;
1208 $self->run_indb_circ_test;
1211 circ_test_success => $self->circ_test_success,
1212 failure_events => [],
1213 failure_codes => [],
1214 matchpoint => $self->circ_matrix_matchpoint
1217 unless($self->circ_test_success) {
1218 $results->{"failure_codes"} = [ $self->matrix_test_result_codes ];
1219 $results->{"failure_events"} = [ $self->matrix_test_result_events ];
1222 if($self->circ_matrix_matchpoint) {
1223 my $duration_rule = $self->circ_matrix_matchpoint->duration_rule;
1224 my $recurring_fine_rule = $self->circ_matrix_matchpoint->recurring_fine_rule;
1225 my $max_fine_rule = $self->circ_matrix_matchpoint->max_fine_rule;
1226 my $hard_due_date = $self->circ_matrix_matchpoint->hard_due_date;
1228 my $policy = $self->get_circ_policy(
1229 $duration_rule, $recurring_fine_rule, $max_fine_rule, $hard_due_date);
1231 $$results{$_} = $$policy{$_} for keys %$policy;
1237 # ---------------------------------------------------------------------
1238 # Loads the circ policy info for duration, recurring fine, and max
1239 # fine based on the current copy
1240 # ---------------------------------------------------------------------
1241 sub get_circ_policy {
1242 my($self, $duration_rule, $recurring_fine_rule, $max_fine_rule, $hard_due_date) = @_;
1245 duration_rule => $duration_rule->name,
1246 recurring_fine_rule => $recurring_fine_rule->name,
1247 max_fine_rule => $max_fine_rule->name,
1248 max_fine => $self->get_max_fine_amount($max_fine_rule),
1249 fine_interval => $recurring_fine_rule->recurrence_interval,
1250 renewal_remaining => $duration_rule->max_renewals,
1251 grace_period => $recurring_fine_rule->grace_period
1254 if($hard_due_date) {
1255 $policy->{duration_date_ceiling} = $hard_due_date->ceiling_date;
1256 $policy->{duration_date_ceiling_force} = $hard_due_date->forceto;
1259 $policy->{duration_date_ceiling} = undef;
1260 $policy->{duration_date_ceiling_force} = undef;
1263 $policy->{duration} = $duration_rule->shrt
1264 if $self->copy->loan_duration == OILS_CIRC_DURATION_SHORT;
1265 $policy->{duration} = $duration_rule->normal
1266 if $self->copy->loan_duration == OILS_CIRC_DURATION_NORMAL;
1267 $policy->{duration} = $duration_rule->extended
1268 if $self->copy->loan_duration == OILS_CIRC_DURATION_EXTENDED;
1270 $policy->{recurring_fine} = $recurring_fine_rule->low
1271 if $self->copy->fine_level == OILS_REC_FINE_LEVEL_LOW;
1272 $policy->{recurring_fine} = $recurring_fine_rule->normal
1273 if $self->copy->fine_level == OILS_REC_FINE_LEVEL_NORMAL;
1274 $policy->{recurring_fine} = $recurring_fine_rule->high
1275 if $self->copy->fine_level == OILS_REC_FINE_LEVEL_HIGH;
1280 sub get_max_fine_amount {
1282 my $max_fine_rule = shift;
1283 my $max_amount = $max_fine_rule->amount;
1285 # if is_percent is true then the max->amount is
1286 # use as a percentage of the copy price
1287 if ($U->is_true($max_fine_rule->is_percent)) {
1288 my $price = $U->get_copy_price($self->editor, $self->copy, $self->volume);
1289 $max_amount = $price * $max_fine_rule->amount / 100;
1291 $U->ou_ancestor_setting_value(
1293 'circ.max_fine.cap_at_price',
1297 my $price = $U->get_copy_price($self->editor, $self->copy, $self->volume);
1298 $max_amount = ( $price && $max_amount > $price ) ? $price : $max_amount;
1306 sub run_copy_permit_scripts {
1308 my $copy = $self->copy || return;
1309 my $runner = $self->script_runner;
1313 if(!$self->legacy_script_support) {
1314 my $results = $self->run_indb_circ_test;
1315 push @allevents, $self->matrix_test_result_events
1316 unless $self->circ_test_success;
1319 # ---------------------------------------------------------------------
1320 # Capture all of the copy permit events
1321 # ---------------------------------------------------------------------
1322 $runner->load($self->circ_permit_copy);
1323 my $result = $runner->run or
1324 throw OpenSRF::EX::ERROR ("Circ Permit Copy Script Died: $@");
1325 my $copy_events = $result->{events};
1327 # ---------------------------------------------------------------------
1328 # Now collect all of the events together
1329 # ---------------------------------------------------------------------
1330 push( @allevents, OpenILS::Event->new($_)) for @$copy_events;
1333 # See if this copy has an alert message
1334 my $ae = $self->check_copy_alert();
1335 push( @allevents, $ae ) if $ae;
1337 # uniquify the events
1338 my %hash = map { ($_->{ilsevent} => $_) } @allevents;
1339 @allevents = values %hash;
1341 $logger->info("circulator: permit_copy script returned events: @allevents") if @allevents;
1343 $self->push_events(@allevents);
1347 sub check_copy_alert {
1349 return undef if $self->is_renewal;
1350 return OpenILS::Event->new(
1351 'COPY_ALERT_MESSAGE', payload => $self->copy->alert_message)
1352 if $self->copy and $self->copy->alert_message;
1358 # --------------------------------------------------------------------------
1359 # If the call is overriding and has permissions to override every collected
1360 # event, the are cleared. Any event that the caller does not have
1361 # permission to override, will be left in the event list and bail_out will
1363 # XXX We need code in here to cancel any holds/transits on copies
1364 # that are being force-checked out
1365 # --------------------------------------------------------------------------
1366 sub override_events {
1368 my @events = @{$self->events};
1369 return unless @events;
1371 if(!$self->override) {
1372 return $self->bail_out(1)
1373 if( @events > 1 or $events[0]->{textcode} ne 'SUCCESS' );
1378 for my $e (@events) {
1379 my $tc = $e->{textcode};
1380 next if $tc eq 'SUCCESS';
1381 my $ov = "$tc.override";
1382 $logger->info("circulator: attempting to override event: $ov");
1384 return $self->bail_on_events($self->editor->event)
1385 unless( $self->editor->allowed($ov) );
1390 # --------------------------------------------------------------------------
1391 # If there is an open claimsreturn circ on the requested copy, close the
1392 # circ if overriding, otherwise bail out
1393 # --------------------------------------------------------------------------
1394 sub handle_claims_returned {
1396 my $copy = $self->copy;
1398 my $CR = $self->editor->search_action_circulation(
1400 target_copy => $copy->id,
1401 stop_fines => OILS_STOP_FINES_CLAIMSRETURNED,
1402 checkin_time => undef,
1406 return unless ($CR = $CR->[0]);
1410 # - If the caller has set the override flag, we will check the item in
1411 if($self->override) {
1413 $CR->checkin_time('now');
1414 $CR->checkin_scan_time('now');
1415 $CR->checkin_lib($self->circ_lib);
1416 $CR->checkin_workstation($self->editor->requestor->wsid);
1417 $CR->checkin_staff($self->editor->requestor->id);
1419 $evt = $self->editor->event
1420 unless $self->editor->update_action_circulation($CR);
1423 $evt = OpenILS::Event->new('CIRC_CLAIMS_RETURNED');
1426 $self->bail_on_events($evt) if $evt;
1431 # --------------------------------------------------------------------------
1432 # This performs the checkout
1433 # --------------------------------------------------------------------------
1437 $self->log_me("do_checkout()");
1439 # make sure perms are good if this isn't a renewal
1440 unless( $self->is_renewal ) {
1441 return $self->bail_on_events($self->editor->event)
1442 unless( $self->editor->allowed('COPY_CHECKOUT') );
1445 # verify the permit key
1446 unless( $self->check_permit_key ) {
1447 if( $self->permit_override ) {
1448 return $self->bail_on_events($self->editor->event)
1449 unless $self->editor->allowed('CIRC_PERMIT_OVERRIDE');
1451 return $self->bail_on_events(OpenILS::Event->new('CIRC_PERMIT_BAD_KEY'))
1455 # if this is a non-cataloged circ, build the circ and finish
1456 if( $self->is_noncat ) {
1457 $self->checkout_noncat;
1459 OpenILS::Event->new('SUCCESS',
1460 payload => { noncat_circ => $self->circ }));
1464 if( $self->is_precat ) {
1465 $self->make_precat_copy;
1466 return if $self->bail_out;
1468 } elsif( $self->copy->call_number == OILS_PRECAT_CALL_NUMBER ) {
1469 return $self->bail_on_events(OpenILS::Event->new('ITEM_NOT_CATALOGED'));
1472 $self->do_copy_checks;
1473 return if $self->bail_out;
1475 $self->run_checkout_scripts();
1476 return if $self->bail_out;
1478 $self->build_checkout_circ_object();
1479 return if $self->bail_out;
1481 my $modify_to_start = $self->booking_adjusted_due_date();
1482 return if $self->bail_out;
1484 $self->apply_modified_due_date($modify_to_start);
1485 return if $self->bail_out;
1487 return $self->bail_on_events($self->editor->event)
1488 unless $self->editor->create_action_circulation($self->circ);
1490 # refresh the circ to force local time zone for now
1491 $self->circ($self->editor->retrieve_action_circulation($self->circ->id));
1493 if($self->limit_groups) {
1494 $self->editor->json_query({ from => ['action.link_circ_limit_groups', $self->circ->id, $self->limit_groups] });
1497 $self->copy->status(OILS_COPY_STATUS_CHECKED_OUT);
1499 return if $self->bail_out;
1501 $self->apply_deposit_fee();
1502 return if $self->bail_out;
1504 $self->handle_checkout_holds();
1505 return if $self->bail_out;
1507 # ------------------------------------------------------------------------------
1508 # Update the patron penalty info in the DB. Run it for permit-overrides
1509 # since the penalties are not updated during the permit phase
1510 # ------------------------------------------------------------------------------
1511 OpenILS::Utils::Penalty->calculate_penalties($self->editor, $self->patron->id, $self->circ_lib);
1513 my $record = $U->record_to_mvr($self->title) unless $self->is_precat;
1516 if($self->is_renewal) {
1517 # flesh the billing summary for the checked-in circ
1518 $pcirc = $self->editor->retrieve_action_circulation([
1520 {flesh => 2, flesh_fields => {circ => ['billable_transaction'], mbt => ['summary']}}
1525 OpenILS::Event->new('SUCCESS',
1527 copy => $U->unflesh_copy($self->copy),
1528 volume => $self->volume,
1529 circ => $self->circ,
1531 holds_fulfilled => $self->fulfilled_holds,
1532 deposit_billing => $self->deposit_billing,
1533 rental_billing => $self->rental_billing,
1534 parent_circ => $pcirc,
1535 patron => ($self->return_patron) ? $self->patron : undef,
1536 patron_money => $self->editor->retrieve_money_user_summary($self->patron->id)
1542 sub apply_deposit_fee {
1544 my $copy = $self->copy;
1546 ($self->is_deposit and not $self->is_deposit_exempt) or
1547 ($self->is_rental and not $self->is_rental_exempt);
1549 return if $self->is_deposit and $self->skip_deposit_fee;
1550 return if $self->is_rental and $self->skip_rental_fee;
1552 my $bill = Fieldmapper::money::billing->new;
1553 my $amount = $copy->deposit_amount;
1557 if($self->is_deposit) {
1558 $billing_type = OILS_BILLING_TYPE_DEPOSIT;
1560 $self->deposit_billing($bill);
1562 $billing_type = OILS_BILLING_TYPE_RENTAL;
1564 $self->rental_billing($bill);
1567 $bill->xact($self->circ->id);
1568 $bill->amount($amount);
1569 $bill->note(OILS_BILLING_NOTE_SYSTEM);
1570 $bill->billing_type($billing_type);
1571 $bill->btype($btype);
1572 $self->editor->create_money_billing($bill) or $self->bail_on_events($self->editor->event);
1574 $logger->info("circulator: charged $amount on checkout with billing type $billing_type");
1579 my $copy = $self->copy;
1581 my $stat = $copy->status if ref $copy->status;
1582 my $loc = $copy->location if ref $copy->location;
1583 my $circ_lib = $copy->circ_lib if ref $copy->circ_lib;
1585 $copy->status($stat->id) if $stat;
1586 $copy->location($loc->id) if $loc;
1587 $copy->circ_lib($circ_lib->id) if $circ_lib;
1588 $copy->editor($self->editor->requestor->id);
1589 $copy->edit_date('now');
1590 $copy->age_protect($copy->age_protect->id) if ref $copy->age_protect;
1592 return $self->bail_on_events($self->editor->event)
1593 unless $self->editor->update_asset_copy($self->copy);
1595 $copy->status($U->copy_status($copy->status));
1596 $copy->location($loc) if $loc;
1597 $copy->circ_lib($circ_lib) if $circ_lib;
1600 sub update_reservation {
1602 my $reservation = $self->reservation;
1604 my $usr = $reservation->usr;
1605 my $target_rt = $reservation->target_resource_type;
1606 my $target_r = $reservation->target_resource;
1607 my $current_r = $reservation->current_resource;
1609 $reservation->usr($usr->id) if ref $usr;
1610 $reservation->target_resource_type($target_rt->id) if ref $target_rt;
1611 $reservation->target_resource($target_r->id) if ref $target_r;
1612 $reservation->current_resource($current_r->id) if ref $current_r;
1614 return $self->bail_on_events($self->editor->event)
1615 unless $self->editor->update_booking_reservation($self->reservation);
1618 ($reservation, $evt) = $U->fetch_booking_reservation($reservation->id);
1619 $self->reservation($reservation);
1623 sub bail_on_events {
1624 my( $self, @evts ) = @_;
1625 $self->push_events(@evts);
1630 # ------------------------------------------------------------------------------
1631 # When an item is checked out, see if we can fulfill a hold for this patron
1632 # ------------------------------------------------------------------------------
1633 sub handle_checkout_holds {
1635 my $copy = $self->copy;
1636 my $patron = $self->patron;
1638 my $e = $self->editor;
1639 $self->fulfilled_holds([]);
1641 # pre/non-cats can't fulfill a hold
1642 return if $self->is_precat or $self->is_noncat;
1644 my $hold = $e->search_action_hold_request({
1645 current_copy => $copy->id ,
1646 cancel_time => undef,
1647 fulfillment_time => undef,
1649 {expire_time => undef},
1650 {expire_time => {'>' => 'now'}}
1654 if($hold and $hold->usr != $patron->id) {
1655 # reset the hold since the copy is now checked out
1657 $logger->info("circulator: un-targeting hold ".$hold->id.
1658 " because copy ".$copy->id." is getting checked out");
1660 $hold->clear_prev_check_time;
1661 $hold->clear_current_copy;
1662 $hold->clear_capture_time;
1663 $hold->clear_shelf_time;
1664 $hold->clear_shelf_expire_time;
1665 $hold->clear_current_shelf_lib;
1667 return $self->bail_on_event($e->event)
1668 unless $e->update_action_hold_request($hold);
1674 $hold = $self->find_related_user_hold($copy, $patron) or return;
1675 $logger->info("circulator: found related hold to fulfill in checkout");
1678 $logger->debug("circulator: checkout fulfilling hold " . $hold->id);
1680 # if the hold was never officially captured, capture it.
1681 $hold->current_copy($copy->id);
1682 $hold->capture_time('now') unless $hold->capture_time;
1683 $hold->fulfillment_time('now');
1684 $hold->fulfillment_staff($e->requestor->id);
1685 $hold->fulfillment_lib($self->circ_lib);
1687 return $self->bail_on_events($e->event)
1688 unless $e->update_action_hold_request($hold);
1690 $holdcode->delete_hold_copy_maps($e, $hold->id);
1691 return $self->fulfilled_holds([$hold->id]);
1695 # ------------------------------------------------------------------------------
1696 # If the circ.checkout_fill_related_hold setting is turned on and no hold for
1697 # the patron directly targets the checked out item, see if there is another hold
1698 # for the patron that could be fulfilled by the checked out item. Fulfill the
1699 # oldest hold and only fulfill 1 of them.
1701 # For "another hold":
1703 # First, check for one that the copy matches via hold_copy_map, ensuring that
1704 # *any* hold type that this copy could fill may end up filled.
1706 # Then, if circ.checkout_fill_related_hold_exact_match_only is not enabled, look
1707 # for a Title (T) or Volume (V) hold that matches the item. This allows items
1708 # that are non-requestable to count as capturing those hold types.
1709 # ------------------------------------------------------------------------------
1710 sub find_related_user_hold {
1711 my($self, $copy, $patron) = @_;
1712 my $e = $self->editor;
1714 return undef if $self->volume->id == OILS_PRECAT_CALL_NUMBER;
1716 return undef unless $U->ou_ancestor_setting_value(
1717 $self->circ_lib, 'circ.checkout_fills_related_hold', $e);
1719 # find the oldest unfulfilled hold that has not yet hit the holds shelf.
1721 select => {ahr => ['id']},
1730 fkey => 'current_copy',
1731 type => 'left' # there may be no current_copy
1738 fulfillment_time => undef,
1739 cancel_time => undef,
1741 {expire_time => undef},
1742 {expire_time => {'>' => 'now'}}
1746 target_copy => $self->copy->id
1750 {id => undef}, # left-join copy may be nonexistent
1751 {status => {'!=' => OILS_COPY_STATUS_ON_HOLDS_SHELF}},
1755 order_by => {ahr => {request_time => {direction => 'asc'}}},
1759 my $hold_info = $e->json_query($args)->[0];
1760 return $e->retrieve_action_hold_request($hold_info->{id}) if $hold_info;
1761 return undef if $U->ou_ancestor_setting_value(
1762 $self->circ_lib, 'circ.checkout_fills_related_hold_exact_match_only', $e);
1764 # find the oldest unfulfilled hold that has not yet hit the holds shelf.
1766 select => {ahr => ['id']},
1771 fkey => 'current_copy',
1772 type => 'left' # there may be no current_copy
1779 fulfillment_time => undef,
1780 cancel_time => undef,
1782 {expire_time => undef},
1783 {expire_time => {'>' => 'now'}}
1790 target => $self->volume->id
1796 target => $self->title->id
1802 {id => undef}, # left-join copy may be nonexistent
1803 {status => {'!=' => OILS_COPY_STATUS_ON_HOLDS_SHELF}},
1807 order_by => {ahr => {request_time => {direction => 'asc'}}},
1811 $hold_info = $e->json_query($args)->[0];
1812 return $e->retrieve_action_hold_request($hold_info->{id}) if $hold_info;
1817 sub run_checkout_scripts {
1822 my $runner = $self->script_runner;
1831 my $hard_due_date_name;
1833 if(!$self->legacy_script_support) {
1834 $self->run_indb_circ_test();
1835 $duration = $self->circ_matrix_matchpoint->duration_rule;
1836 $recurring = $self->circ_matrix_matchpoint->recurring_fine_rule;
1837 $max_fine = $self->circ_matrix_matchpoint->max_fine_rule;
1838 $hard_due_date = $self->circ_matrix_matchpoint->hard_due_date;
1842 $runner->load($self->circ_duration);
1844 my $result = $runner->run or
1845 throw OpenSRF::EX::ERROR ("Circ Duration Script Died: $@");
1847 $duration_name = $result->{durationRule};
1848 $recurring_name = $result->{recurringFinesRule};
1849 $max_fine_name = $result->{maxFine};
1850 $hard_due_date_name = $result->{hardDueDate};
1853 $duration_name = $duration->name if $duration;
1854 if( $duration_name ne OILS_UNLIMITED_CIRC_DURATION ) {
1857 ($duration, $evt) = $U->fetch_circ_duration_by_name($duration_name);
1858 return $self->bail_on_events($evt) if ($evt && !$nobail);
1860 ($recurring, $evt) = $U->fetch_recurring_fine_by_name($recurring_name);
1861 return $self->bail_on_events($evt) if ($evt && !$nobail);
1863 ($max_fine, $evt) = $U->fetch_max_fine_by_name($max_fine_name);
1864 return $self->bail_on_events($evt) if ($evt && !$nobail);
1866 if($hard_due_date_name) {
1867 ($hard_due_date, $evt) = $U->fetch_hard_due_date_by_name($hard_due_date_name);
1868 return $self->bail_on_events($evt) if ($evt && !$nobail);
1874 # The item circulates with an unlimited duration
1878 $hard_due_date = undef;
1881 $self->duration_rule($duration);
1882 $self->recurring_fines_rule($recurring);
1883 $self->max_fine_rule($max_fine);
1884 $self->hard_due_date($hard_due_date);
1888 sub build_checkout_circ_object {
1891 my $circ = Fieldmapper::action::circulation->new;
1892 my $duration = $self->duration_rule;
1893 my $max = $self->max_fine_rule;
1894 my $recurring = $self->recurring_fines_rule;
1895 my $hard_due_date = $self->hard_due_date;
1896 my $copy = $self->copy;
1897 my $patron = $self->patron;
1898 my $duration_date_ceiling;
1899 my $duration_date_ceiling_force;
1903 my $policy = $self->get_circ_policy($duration, $recurring, $max, $hard_due_date);
1904 $duration_date_ceiling = $policy->{duration_date_ceiling};
1905 $duration_date_ceiling_force = $policy->{duration_date_ceiling_force};
1907 my $dname = $duration->name;
1908 my $mname = $max->name;
1909 my $rname = $recurring->name;
1911 if($hard_due_date) {
1912 $hdname = $hard_due_date->name;
1915 $logger->debug("circulator: building circulation ".
1916 "with duration=$dname, maxfine=$mname, recurring=$rname, hard due date=$hdname");
1918 $circ->duration($policy->{duration});
1919 $circ->recurring_fine($policy->{recurring_fine});
1920 $circ->duration_rule($duration->name);
1921 $circ->recurring_fine_rule($recurring->name);
1922 $circ->max_fine_rule($max->name);
1923 $circ->max_fine($policy->{max_fine});
1924 $circ->fine_interval($recurring->recurrence_interval);
1925 $circ->renewal_remaining($duration->max_renewals);
1926 $circ->grace_period($policy->{grace_period});
1930 $logger->info("circulator: copy found with an unlimited circ duration");
1931 $circ->duration_rule(OILS_UNLIMITED_CIRC_DURATION);
1932 $circ->recurring_fine_rule(OILS_UNLIMITED_CIRC_DURATION);
1933 $circ->max_fine_rule(OILS_UNLIMITED_CIRC_DURATION);
1934 $circ->renewal_remaining(0);
1935 $circ->grace_period(0);
1938 $circ->target_copy( $copy->id );
1939 $circ->usr( $patron->id );
1940 $circ->circ_lib( $self->circ_lib );
1941 $circ->workstation($self->editor->requestor->wsid)
1942 if defined $self->editor->requestor->wsid;
1944 # renewals maintain a link to the parent circulation
1945 $circ->parent_circ($self->parent_circ);
1947 if( $self->is_renewal ) {
1948 $circ->opac_renewal('t') if $self->opac_renewal;
1949 $circ->phone_renewal('t') if $self->phone_renewal;
1950 $circ->desk_renewal('t') if $self->desk_renewal;
1951 $circ->renewal_remaining($self->renewal_remaining);
1952 $circ->circ_staff($self->editor->requestor->id);
1956 # if the user provided an overiding checkout time,
1957 # (e.g. the checkout really happened several hours ago), then
1958 # we apply that here. Does this need a perm??
1959 $circ->xact_start(cleanse_ISO8601($self->checkout_time))
1960 if $self->checkout_time;
1962 # if a patron is renewing, 'requestor' will be the patron
1963 $circ->circ_staff($self->editor->requestor->id);
1964 $circ->due_date( $self->create_due_date($circ->duration, $duration_date_ceiling, $duration_date_ceiling_force) ) if $circ->duration;
1969 sub do_reservation_pickup {
1972 $self->log_me("do_reservation_pickup()");
1974 $self->reservation->pickup_time('now');
1977 $self->reservation->current_resource &&
1978 $U->is_true($self->reservation->target_resource_type->catalog_item)
1980 # We used to try to set $self->copy and $self->patron here,
1981 # but that should already be done.
1983 $self->run_checkout_scripts(1);
1985 my $duration = $self->duration_rule;
1986 my $max = $self->max_fine_rule;
1987 my $recurring = $self->recurring_fines_rule;
1989 if ($duration && $max && $recurring) {
1990 my $policy = $self->get_circ_policy($duration, $recurring, $max);
1992 my $dname = $duration->name;
1993 my $mname = $max->name;
1994 my $rname = $recurring->name;
1996 $logger->debug("circulator: updating reservation ".
1997 "with duration=$dname, maxfine=$mname, recurring=$rname");
1999 $self->reservation->fine_amount($policy->{recurring_fine});
2000 $self->reservation->max_fine($policy->{max_fine});
2001 $self->reservation->fine_interval($recurring->recurrence_interval);
2004 $self->copy->status(OILS_COPY_STATUS_CHECKED_OUT);
2005 $self->update_copy();
2008 $self->reservation->fine_amount(
2009 $self->reservation->target_resource_type->fine_amount
2011 $self->reservation->max_fine(
2012 $self->reservation->target_resource_type->max_fine
2014 $self->reservation->fine_interval(
2015 $self->reservation->target_resource_type->fine_interval
2019 $self->update_reservation();
2022 sub do_reservation_return {
2024 my $request = shift;
2026 $self->log_me("do_reservation_return()");
2028 if (not ref $self->reservation) {
2029 my ($reservation, $evt) =
2030 $U->fetch_booking_reservation($self->reservation);
2031 return $self->bail_on_events($evt) if $evt;
2032 $self->reservation($reservation);
2035 $self->generate_fines(1);
2036 $self->reservation->return_time('now');
2037 $self->update_reservation();
2038 $self->reshelve_copy if $self->copy;
2040 if ( $self->reservation->current_resource && $self->reservation->current_resource->catalog_item ) {
2041 $self->copy( $self->reservation->current_resource->catalog_item );
2045 sub booking_adjusted_due_date {
2047 my $circ = $self->circ;
2048 my $copy = $self->copy;
2050 return undef unless $self->use_booking;
2054 if( $self->due_date ) {
2056 return $self->bail_on_events($self->editor->event)
2057 unless $self->editor->allowed('CIRC_OVERRIDE_DUE_DATE', $self->circ_lib);
2059 $circ->due_date(cleanse_ISO8601($self->due_date));
2063 return unless $copy and $circ->due_date;
2066 my $booking_items = $self->editor->search_booking_resource( { barcode => $copy->barcode } );
2067 if (@$booking_items) {
2068 my $booking_item = $booking_items->[0];
2069 my $resource_type = $self->editor->retrieve_booking_resource_type( $booking_item->type );
2071 my $stop_circ_setting = $U->ou_ancestor_setting_value( $self->circ_lib, 'circ.booking_reservation.stop_circ', $self->editor );
2072 my $shorten_circ_setting = $resource_type->elbow_room ||
2073 $U->ou_ancestor_setting_value( $self->circ_lib, 'circ.booking_reservation.default_elbow_room', $self->editor ) ||
2076 my $booking_ses = OpenSRF::AppSession->create( 'open-ils.booking' );
2077 my $bookings = $booking_ses->request(
2078 'open-ils.booking.reservations.filtered_id_list', $self->editor->authtoken,
2079 { resource => $booking_item->id, search_start => 'now', search_end => $circ->due_date, fields => { cancel_time => undef, return_time => undef}}
2081 $booking_ses->disconnect;
2083 my $dt_parser = DateTime::Format::ISO8601->new;
2084 my $due_date = $dt_parser->parse_datetime( cleanse_ISO8601($circ->due_date) );
2086 for my $bid (@$bookings) {
2088 my $booking = $self->editor->retrieve_booking_reservation( $bid );
2090 my $booking_start = $dt_parser->parse_datetime( cleanse_ISO8601($booking->start_time) );
2091 my $booking_end = $dt_parser->parse_datetime( cleanse_ISO8601($booking->end_time) );
2093 return $self->bail_on_events( OpenILS::Event->new('COPY_RESERVED') )
2094 if ($booking_start < DateTime->now);
2097 if ($U->is_true($stop_circ_setting)) {
2098 $self->bail_on_events( OpenILS::Event->new('COPY_RESERVED') );
2100 $due_date = $booking_start->subtract( seconds => interval_to_seconds($shorten_circ_setting) );
2101 $self->bail_on_events( OpenILS::Event->new('COPY_RESERVED') ) if ($due_date < DateTime->now);
2104 # We set the circ duration here only to affect the logic that will
2105 # later (in a DB trigger) mangle the time part of the due date to
2106 # 11:59pm. Having any circ duration that is not a whole number of
2107 # days is enough to prevent the "correction."
2108 my $new_circ_duration = $due_date->epoch - time;
2109 $new_circ_duration++ if $new_circ_duration % 86400 == 0;
2110 $circ->duration("$new_circ_duration seconds");
2112 $circ->due_date(cleanse_ISO8601($due_date->strftime('%FT%T%z')));
2116 return $self->bail_on_events($self->editor->event)
2117 unless $self->editor->allowed('CIRC_OVERRIDE_DUE_DATE', $self->circ_lib);
2123 sub apply_modified_due_date {
2125 my $shift_earlier = shift;
2126 my $circ = $self->circ;
2127 my $copy = $self->copy;
2129 if( $self->due_date ) {
2131 return $self->bail_on_events($self->editor->event)
2132 unless $self->editor->allowed('CIRC_OVERRIDE_DUE_DATE', $self->circ_lib);
2134 $circ->due_date(cleanse_ISO8601($self->due_date));
2138 # if the due_date lands on a day when the location is closed
2139 return unless $copy and $circ->due_date;
2141 #my $org = (ref $copy->circ_lib) ? $copy->circ_lib->id : $copy->circ_lib;
2143 # due-date overlap should be determined by the location the item
2144 # is checked out from, not the owning or circ lib of the item
2145 my $org = $self->circ_lib;
2147 $logger->info("circulator: circ searching for closed date overlap on lib $org".
2148 " with an item due date of ".$circ->due_date );
2150 my $dateinfo = $U->storagereq(
2151 'open-ils.storage.actor.org_unit.closed_date.overlap',
2152 $org, $circ->due_date );
2155 $logger->info("circulator: $dateinfo : circ due data / close date overlap found : due_date=".
2156 $circ->due_date." start=". $dateinfo->{start}.", end=".$dateinfo->{end});
2158 # XXX make the behavior more dynamic
2159 # for now, we just push the due date to after the close date
2160 if ($shift_earlier) {
2161 $circ->due_date($dateinfo->{start});
2163 $circ->due_date($dateinfo->{end});
2171 sub create_due_date {
2172 my( $self, $duration, $date_ceiling, $force_date ) = @_;
2174 # if there is a raw time component (e.g. from postgres),
2175 # turn it into an interval that interval_to_seconds can parse
2176 $duration =~ s/(\d{2}):(\d{2}):(\d{2})/$1 h $2 m $3 s/o;
2178 # for now, use the server timezone. TODO: use workstation org timezone
2179 my $due_date = DateTime->now(time_zone => 'local');
2181 # add the circ duration
2182 $due_date->add(seconds => OpenSRF::Utils->interval_to_seconds($duration));
2185 my $cdate = DateTime::Format::ISO8601->new->parse_datetime(cleanse_ISO8601($date_ceiling));
2186 if ($cdate > DateTime->now and ($cdate < $due_date or $U->is_true( $force_date ))) {
2187 $logger->info("circulator: overriding due date with date ceiling: $date_ceiling");
2192 # return ISO8601 time with timezone
2193 return $due_date->strftime('%FT%T%z');
2198 sub make_precat_copy {
2200 my $copy = $self->copy;
2203 $logger->debug("circulator: Pre-cat copy already exists in checkout: ID=" . $copy->id);
2205 $copy->editor($self->editor->requestor->id);
2206 $copy->edit_date('now');
2207 $copy->dummy_title($self->dummy_title || $copy->dummy_title || '');
2208 $copy->dummy_isbn($self->dummy_isbn || $copy->dummy_isbn || '');
2209 $copy->dummy_author($self->dummy_author || $copy->dummy_author || '');
2210 $copy->circ_modifier($self->circ_modifier || $copy->circ_modifier);
2211 $self->update_copy();
2215 $logger->info("circulator: Creating a new precataloged ".
2216 "copy in checkout with barcode " . $self->copy_barcode);
2218 $copy = Fieldmapper::asset::copy->new;
2219 $copy->circ_lib($self->circ_lib);
2220 $copy->creator($self->editor->requestor->id);
2221 $copy->editor($self->editor->requestor->id);
2222 $copy->barcode($self->copy_barcode);
2223 $copy->call_number(OILS_PRECAT_CALL_NUMBER);
2224 $copy->loan_duration(OILS_PRECAT_COPY_LOAN_DURATION);
2225 $copy->fine_level(OILS_PRECAT_COPY_FINE_LEVEL);
2227 $copy->dummy_title($self->dummy_title || "");
2228 $copy->dummy_author($self->dummy_author || "");
2229 $copy->dummy_isbn($self->dummy_isbn || "");
2230 $copy->circ_modifier($self->circ_modifier);
2233 # See if we need to override the circ_lib for the copy with a configured circ_lib
2234 # Setting is shortname of the org unit
2235 my $precat_circ_lib = $U->ou_ancestor_setting_value(
2236 $self->circ_lib, 'circ.pre_cat_copy_circ_lib', $self->editor);
2238 if($precat_circ_lib) {
2239 my $org = $self->editor->search_actor_org_unit({shortname => $precat_circ_lib})->[0];
2242 $self->bail_on_events($self->editor->event);
2246 $copy->circ_lib($org->id);
2250 unless( $self->copy($self->editor->create_asset_copy($copy)) ) {
2252 $self->push_events($self->editor->event);
2256 # this is a little bit of a hack, but we need to
2257 # get the copy into the script runner
2258 $self->script_runner->insert("environment.copy", $copy, 1) if $self->script_runner;
2262 sub checkout_noncat {
2268 my $lib = $self->noncat_circ_lib || $self->circ_lib;
2269 my $count = $self->noncat_count || 1;
2270 my $cotime = cleanse_ISO8601($self->checkout_time) || "";
2272 $logger->info("circulator: circ creating $count noncat circs with checkout time $cotime");
2276 ( $circ, $evt ) = OpenILS::Application::Circ::NonCat::create_non_cat_circ(
2277 $self->editor->requestor->id,
2285 $self->push_events($evt);
2293 # If a copy goes into transit and is then checked in before the transit checkin
2294 # interval has expired, push an event onto the overridable events list.
2295 sub check_transit_checkin_interval {
2298 # only concerned with in-transit items
2299 return unless $U->copy_status($self->copy->status)->id == OILS_COPY_STATUS_IN_TRANSIT;
2301 # no interval, no problem
2302 my $interval = $U->ou_ancestor_setting_value($self->circ_lib, 'circ.transit.min_checkin_interval');
2303 return unless $interval;
2305 # capture the transit so we don't have to fetch it again later during checkin
2307 $self->editor->search_action_transit_copy(
2308 {target_copy => $self->copy->id, dest_recv_time => undef}
2312 # transit from X to X for whatever reason has no min interval
2313 return if $self->transit->source == $self->transit->dest;
2315 my $seconds = OpenSRF::Utils->interval_to_seconds($interval);
2316 my $t_start = DateTime::Format::ISO8601->new->parse_datetime(cleanse_ISO8601($self->transit->source_send_time));
2317 my $horizon = $t_start->add(seconds => $seconds);
2319 # See if we are still within the transit checkin forbidden range
2320 $self->push_events(OpenILS::Event->new('TRANSIT_CHECKIN_INTERVAL_BLOCK'))
2321 if $horizon > DateTime->now;
2324 # Retarget local holds at checkin
2325 sub checkin_retarget {
2327 return unless $self->retarget_mode =~ m/retarget/; # Retargeting?
2328 return unless $self->is_checkin; # Renewals need not be checked
2329 return if $self->capture eq 'nocapture'; # Not capturing holds anyway? Move on.
2330 return if $self->is_precat; # No holds for precats
2331 return unless $self->circ_lib == $self->copy->circ_lib; # Item isn't "home"? Don't check.
2332 return unless $U->is_true($self->copy->holdable); # Not holdable, shouldn't capture holds.
2333 my $status = $U->copy_status($self->copy->status);
2334 return unless $U->is_true($status->holdable); # Current status not holdable means no hold will ever target the item
2335 # Specifically target items that are likely new (by status ID)
2336 return unless $status->id == OILS_COPY_STATUS_IN_PROCESS || $self->retarget_mode =~ m/\.all/;
2337 my $location = $self->copy->location;
2338 if(!ref($location)) {
2339 $location = $self->editor->retrieve_asset_copy_location($self->copy->location);
2340 $self->copy->location($location);
2342 return unless $U->is_true($location->holdable); # Don't bother on non-holdable locations
2344 # Fetch holds for the bib
2345 my ($result) = $holdcode->method_lookup('open-ils.circ.holds.retrieve_all_from_title')->run(
2346 $self->editor->authtoken,
2349 capture_time => undef, # No touching captured holds
2350 frozen => 'f', # Don't bother with frozen holds
2351 pickup_lib => $self->circ_lib # Only holds actually here
2354 # Error? Skip the step.
2355 return if exists $result->{"ilsevent"};
2359 foreach my $holdlist (keys %{$result}) {
2360 push @$holds, @{$result->{$holdlist}};
2363 return if scalar(@$holds) == 0; # No holds, no retargeting
2365 # Check for parts on this copy
2366 my $parts = $self->editor->search_asset_copy_part_map({ target_copy => $self->copy->id });
2367 my %parts_hash = ();
2368 %parts_hash = map {$_->id, 1} @$parts if @$parts;
2370 # Loop over holds in request-ish order
2371 # Stage 1: Get them into request-ish order
2372 # Also grab type and target for skipping low hanging ones
2373 $result = $self->editor->json_query({
2374 "select" => { "ahr" => ["id", "hold_type", "target"] },
2375 "from" => { "ahr" => { "au" => { "fkey" => "usr", "join" => "pgt"} } },
2376 "where" => { "id" => $holds },
2378 { "class" => "pgt", "field" => "hold_priority"},
2379 { "class" => "ahr", "field" => "cut_in_line", "direction" => "desc", "transform" => "coalesce", "params" => ['f']},
2380 { "class" => "ahr", "field" => "selection_depth", "direction" => "desc"},
2381 { "class" => "ahr", "field" => "request_time"}
2386 if (ref $result eq "ARRAY" and scalar @$result) {
2387 foreach (@{$result}) {
2388 # Copy level, but not this copy?
2389 next if ($_->{hold_type} eq 'C' or $_->{hold_type} eq 'R' or $_->{hold_type} eq 'F'
2390 and $_->{target} != $self->copy->id);
2391 # Volume level, but not this volume?
2392 next if ($_->{hold_type} eq 'V' and $_->{target} != $self->volume->id);
2393 if(@$parts) { # We have parts?
2395 next if ($_->{hold_type} eq 'T');
2396 # Skip part holds for parts not on this copy
2397 next if ($_->{hold_type} eq 'P' and not $parts_hash{$_->{target}});
2399 # No parts, no part holds
2400 next if ($_->{hold_type} eq 'P');
2402 # So much for easy stuff, attempt a retarget!
2403 my $tresult = $U->storagereq('open-ils.storage.action.hold_request.copy_targeter', undef, $_->{id}, $self->copy->id);
2404 if(ref $tresult eq "ARRAY" and scalar @$tresult) {
2405 last if(exists $tresult->[0]->{found_copy} and $tresult->[0]->{found_copy});
2413 $self->log_me("do_checkin()");
2415 return $self->bail_on_events(
2416 OpenILS::Event->new('ASSET_COPY_NOT_FOUND'))
2419 $self->check_transit_checkin_interval;
2420 $self->checkin_retarget;
2422 # the renew code and mk_env should have already found our circulation object
2423 unless( $self->circ ) {
2425 my $circs = $self->editor->search_action_circulation(
2426 { target_copy => $self->copy->id, checkin_time => undef });
2428 $self->circ($$circs[0]);
2430 # for now, just warn if there are multiple open circs on a copy
2431 $logger->warn("circulator: we have ".scalar(@$circs).
2432 " open circs for copy " .$self->copy->id."!!") if @$circs > 1;
2435 # run the fine generator against this circ, if this circ is there
2436 $self->generate_fines_start if $self->circ;
2438 if( $self->checkin_check_holds_shelf() ) {
2439 $self->bail_on_events(OpenILS::Event->new('NO_CHANGE'));
2440 $self->hold($U->fetch_open_hold_by_copy($self->copy->id));
2441 if($self->fake_hold_dest) {
2442 $self->hold->pickup_lib($self->circ_lib);
2444 $self->checkin_flesh_events;
2448 unless( $self->is_renewal ) {
2449 return $self->bail_on_events($self->editor->event)
2450 unless $self->editor->allowed('COPY_CHECKIN');
2453 $self->push_events($self->check_copy_alert());
2454 $self->push_events($self->check_checkin_copy_status());
2456 # if the circ is marked as 'claims returned', add the event to the list
2457 $self->push_events(OpenILS::Event->new('CIRC_CLAIMS_RETURNED'))
2458 if ($self->circ and $self->circ->stop_fines
2459 and $self->circ->stop_fines eq OILS_STOP_FINES_CLAIMSRETURNED);
2461 $self->check_circ_deposit();
2463 # handle the overridable events
2464 $self->override_events unless $self->is_renewal;
2465 return if $self->bail_out;
2467 if( $self->copy and !$self->transit ) {
2469 $self->editor->search_action_transit_copy(
2470 { target_copy => $self->copy->id, dest_recv_time => undef }
2476 $self->generate_fines_finish;
2477 $self->checkin_handle_circ;
2478 return if $self->bail_out;
2479 $self->checkin_changed(1);
2481 } elsif( $self->transit ) {
2482 my $hold_transit = $self->process_received_transit;
2483 $self->checkin_changed(1);
2485 if( $self->bail_out ) {
2486 $self->checkin_flesh_events;
2490 if( my $e = $self->check_checkin_copy_status() ) {
2491 # If the original copy status is special, alert the caller
2492 my $ev = $self->events;
2493 $self->events([$e]);
2494 $self->override_events;
2495 return if $self->bail_out;
2499 if( $hold_transit or
2500 $U->copy_status($self->copy->status)->id
2501 == OILS_COPY_STATUS_ON_HOLDS_SHELF ) {
2504 if( $hold_transit ) {
2505 $hold = $self->editor->retrieve_action_hold_request($hold_transit->hold);
2507 ($hold) = $U->fetch_open_hold_by_copy($self->copy->id);
2512 if( $hold and $hold->cancel_time ) { # this transited hold was cancelled mid-transit
2514 $logger->info("circulator: we received a transit on a cancelled hold " . $hold->id);
2515 $self->reshelve_copy(1);
2516 $self->cancelled_hold_transit(1);
2517 $self->notify_hold(0); # don't notify for cancelled holds
2518 $self->fake_hold_dest(0);
2519 return if $self->bail_out;
2521 } elsif ($hold and $hold->hold_type eq 'R') {
2523 $self->copy->status(OILS_COPY_STATUS_CATALOGING);
2524 $self->notify_hold(0); # No need to notify
2525 $self->fake_hold_dest(0);
2526 $self->noop(1); # Don't try and capture for other holds/transits now
2527 $self->update_copy();
2528 $hold->fulfillment_time('now');
2529 $self->bail_on_events($self->editor->event)
2530 unless $self->editor->update_action_hold_request($hold);
2534 # hold transited to correct location
2535 if($self->fake_hold_dest) {
2536 $hold->pickup_lib($self->circ_lib);
2538 $self->checkin_flesh_events;
2543 } elsif( $U->copy_status($self->copy->status)->id == OILS_COPY_STATUS_IN_TRANSIT ) {
2545 $logger->warn("circulator: we have a copy ".$self->copy->barcode.
2546 " that is in-transit, but there is no transit.. repairing");
2547 $self->reshelve_copy(1);
2548 return if $self->bail_out;
2551 if( $self->is_renewal ) {
2552 $self->finish_fines_and_voiding;
2553 return if $self->bail_out;
2554 $self->push_events(OpenILS::Event->new('SUCCESS'));
2558 # ------------------------------------------------------------------------------
2559 # Circulations and transits are now closed where necessary. Now go on to see if
2560 # this copy can fulfill a hold or needs to be routed to a different location
2561 # ------------------------------------------------------------------------------
2563 my $needed_for_something = 0; # formerly "needed_for_hold"
2565 if(!$self->noop) { # /not/ a no-op checkin, capture for hold or put item into transit
2567 if (!$self->remote_hold) {
2568 if ($self->use_booking) {
2569 my $potential_hold = $self->hold_capture_is_possible;
2570 my $potential_reservation = $self->reservation_capture_is_possible;
2572 if ($potential_hold and $potential_reservation) {
2573 $logger->info("circulator: item could fulfill either hold or reservation");
2574 $self->push_events(new OpenILS::Event(
2575 "HOLD_RESERVATION_CONFLICT",
2576 "hold" => $potential_hold,
2577 "reservation" => $potential_reservation
2579 return if $self->bail_out;
2580 } elsif ($potential_hold) {
2581 $needed_for_something =
2582 $self->attempt_checkin_hold_capture;
2583 } elsif ($potential_reservation) {
2584 $needed_for_something =
2585 $self->attempt_checkin_reservation_capture;
2588 $needed_for_something = $self->attempt_checkin_hold_capture;
2591 return if $self->bail_out;
2593 unless($needed_for_something) {
2594 my $circ_lib = (ref $self->copy->circ_lib) ?
2595 $self->copy->circ_lib->id : $self->copy->circ_lib;
2597 if( $self->remote_hold ) {
2598 $circ_lib = $self->remote_hold->pickup_lib;
2599 $logger->warn("circulator: Copy ".$self->copy->barcode.
2600 " is on a remote hold's shelf, sending to $circ_lib");
2603 $logger->debug("circulator: circlib=$circ_lib, workstation=".$self->circ_lib);
2605 my $suppress_transit = 0;
2607 if( $circ_lib != $self->circ_lib and not ($self->hold_as_transit and $self->remote_hold) ) {
2608 my $suppress_transit_source = $U->ou_ancestor_setting($self->circ_lib, 'circ.transit.suppress_non_hold');
2609 if($suppress_transit_source && $suppress_transit_source->{value}) {
2610 my $suppress_transit_dest = $U->ou_ancestor_setting($circ_lib, 'circ.transit.suppress_non_hold');
2611 if($suppress_transit_dest && $suppress_transit_source->{value} eq $suppress_transit_dest->{value}) {
2612 $logger->info("circulator: copy is within transit suppress group: ".$self->copy->barcode." ".$suppress_transit_source->{value});
2613 $suppress_transit = 1;
2618 if( $suppress_transit or ( $circ_lib == $self->circ_lib and not ($self->hold_as_transit and $self->remote_hold) ) ) {
2619 # copy is where it needs to be, either for hold or reshelving
2621 $self->checkin_handle_precat();
2622 return if $self->bail_out;
2625 # copy needs to transit "home", or stick here if it's a floating copy
2627 if ($U->is_true( $self->copy->floating ) && !$self->remote_hold) { # copy is floating, stick here
2628 $self->checkin_changed(1);
2629 $self->copy->circ_lib( $self->circ_lib );
2632 my $bc = $self->copy->barcode;
2633 $logger->info("circulator: copy $bc at the wrong location, sending to $circ_lib");
2634 $self->checkin_build_copy_transit($circ_lib);
2635 return if $self->bail_out;
2636 $self->push_events(OpenILS::Event->new('ROUTE_ITEM', org => $circ_lib));
2640 } else { # no-op checkin
2641 if ($U->is_true( $self->copy->floating )) { # XXX floating items still stick where they are even with no-op checkin?
2642 $self->checkin_changed(1);
2643 $self->copy->circ_lib( $self->circ_lib );
2648 if($self->claims_never_checked_out and
2649 $U->ou_ancestor_setting_value($self->circ->circ_lib, 'circ.claim_never_checked_out.mark_missing')) {
2651 # the item was not supposed to be checked out to the user and should now be marked as missing
2652 $self->copy->status(OILS_COPY_STATUS_MISSING);
2656 $self->reshelve_copy unless $needed_for_something;
2659 return if $self->bail_out;
2661 unless($self->checkin_changed) {
2663 $self->push_events(OpenILS::Event->new('NO_CHANGE'));
2664 my $stat = $U->copy_status($self->copy->status)->id;
2666 $self->hold($U->fetch_open_hold_by_copy($self->copy->id))
2667 if( $stat == OILS_COPY_STATUS_ON_HOLDS_SHELF );
2668 $self->bail_out(1); # no need to commit anything
2672 $self->push_events(OpenILS::Event->new('SUCCESS'))
2673 unless @{$self->events};
2676 $self->finish_fines_and_voiding;
2678 OpenILS::Utils::Penalty->calculate_penalties(
2679 $self->editor, $self->patron->id, $self->circ_lib) if $self->patron;
2681 $self->checkin_flesh_events;
2685 sub finish_fines_and_voiding {
2687 return unless $self->circ;
2689 # gather any updates to the circ after fine generation, if there was a circ
2690 $self->generate_fines_finish;
2692 return unless $self->backdate or $self->void_overdues;
2694 # void overdues after fine generation to prevent concurrent DB access to overdue billings
2695 my $note = 'System: Amnesty Checkin' if $self->void_overdues;
2697 my $evt = OpenILS::Application::Circ::CircCommon->void_overdues(
2698 $self->editor, $self->circ, $self->backdate, $note);
2700 return $self->bail_on_events($evt) if $evt;
2702 # make sure the circ isn't closed if we just voided some fines
2703 $evt = OpenILS::Application::Circ::CircCommon->reopen_xact($self->editor, $self->circ->id);
2704 return $self->bail_on_events($evt) if $evt;
2710 # if a deposit was payed for this item, push the event
2711 sub check_circ_deposit {
2713 return unless $self->circ;
2714 my $deposit = $self->editor->search_money_billing(
2716 xact => $self->circ->id,
2718 }, {idlist => 1})->[0];
2720 $self->push_events(OpenILS::Event->new(
2721 'ITEM_DEPOSIT_PAID', payload => $deposit)) if $deposit;
2726 my $force = $self->force || shift;
2727 my $copy = $self->copy;
2729 my $stat = $U->copy_status($copy->status)->id;
2732 $stat != OILS_COPY_STATUS_ON_HOLDS_SHELF and
2733 $stat != OILS_COPY_STATUS_CATALOGING and
2734 $stat != OILS_COPY_STATUS_IN_TRANSIT and
2735 $stat != OILS_COPY_STATUS_RESHELVING )) {
2737 $copy->status( OILS_COPY_STATUS_RESHELVING );
2739 $self->checkin_changed(1);
2744 # Returns true if the item is at the current location
2745 # because it was transited there for a hold and the
2746 # hold has not been fulfilled
2747 sub checkin_check_holds_shelf {
2749 return 0 unless $self->copy;
2752 $U->copy_status($self->copy->status)->id ==
2753 OILS_COPY_STATUS_ON_HOLDS_SHELF;
2755 # Attempt to clear shelf expired holds for this copy
2756 $holdcode->method_lookup('open-ils.circ.hold.clear_shelf.process')->run($self->editor->authtoken, $self->circ_lib, $self->copy->id)
2757 if($self->clear_expired);
2759 # find the hold that put us on the holds shelf
2760 my $holds = $self->editor->search_action_hold_request(
2762 current_copy => $self->copy->id,
2763 capture_time => { '!=' => undef },
2764 fulfillment_time => undef,
2765 cancel_time => undef,
2770 $logger->warn("circulator: copy is on-holds-shelf, but there is no hold - reshelving");
2771 $self->reshelve_copy(1);
2775 my $hold = $$holds[0];
2777 $logger->info("circulator: we found a captured, un-fulfilled hold [".
2778 $hold->id. "] for copy ".$self->copy->barcode);
2780 if( $hold->pickup_lib != $self->circ_lib and not $self->hold_as_transit ) {
2781 my $suppress_transit_circ = $U->ou_ancestor_setting($self->circ_lib, 'circ.transit.suppress_hold');
2782 if($suppress_transit_circ && $suppress_transit_circ->{value}) {
2783 my $suppress_transit_pickup = $U->ou_ancestor_setting($hold->pickup_lib, 'circ.transit.suppress_hold');
2784 if($suppress_transit_pickup && $suppress_transit_circ->{value} eq $suppress_transit_pickup->{value}) {
2785 $logger->info("circulator: hold is within hold transit suppress group .. we're done: ".$self->copy->barcode." ".$suppress_transit_circ->{value});
2786 $self->fake_hold_dest(1);
2792 if( $hold->pickup_lib == $self->circ_lib and not $self->hold_as_transit ) {
2793 $logger->info("circulator: hold is for here .. we're done: ".$self->copy->barcode);
2797 $logger->info("circulator: hold is not for here..");
2798 $self->remote_hold($hold);
2803 sub checkin_handle_precat {
2805 my $copy = $self->copy;
2807 if( $self->is_precat and ($copy->status != OILS_COPY_STATUS_CATALOGING) ) {
2808 $copy->status(OILS_COPY_STATUS_CATALOGING);
2809 $self->update_copy();
2810 $self->checkin_changed(1);
2811 $self->push_events(OpenILS::Event->new('ITEM_NOT_CATALOGED'));
2816 sub checkin_build_copy_transit {
2819 my $copy = $self->copy;
2820 my $transit = Fieldmapper::action::transit_copy->new;
2822 # if we are transiting an item to the shelf shelf, it's a hold transit
2823 if (my $hold = $self->remote_hold) {
2824 $transit = Fieldmapper::action::hold_transit_copy->new;
2825 $transit->hold($hold->id);
2827 # the item is going into transit, remove any shelf-iness
2828 if ($hold->current_shelf_lib or $hold->shelf_time) {
2829 $hold->clear_current_shelf_lib;
2830 $hold->clear_shelf_time;
2831 return $self->bail_on_events($self->editor->event)
2832 unless $self->editor->update_action_hold_request($hold);
2836 #$dest ||= (ref($copy->circ_lib)) ? $copy->circ_lib->id : $copy->circ_lib;
2837 $logger->info("circulator: transiting copy to $dest");
2839 $transit->source($self->circ_lib);
2840 $transit->dest($dest);
2841 $transit->target_copy($copy->id);
2842 $transit->source_send_time('now');
2843 $transit->copy_status( $U->copy_status($copy->status)->id );
2845 $logger->debug("circulator: setting copy status on transit: ".$transit->copy_status);
2847 if ($self->remote_hold) {
2848 return $self->bail_on_events($self->editor->event)
2849 unless $self->editor->create_action_hold_transit_copy($transit);
2851 return $self->bail_on_events($self->editor->event)
2852 unless $self->editor->create_action_transit_copy($transit);
2855 $copy->status(OILS_COPY_STATUS_IN_TRANSIT);
2857 $self->checkin_changed(1);
2861 sub hold_capture_is_possible {
2863 my $copy = $self->copy;
2865 # we've been explicitly told not to capture any holds
2866 return 0 if $self->capture eq 'nocapture';
2868 # See if this copy can fulfill any holds
2869 my $hold = $holdcode->find_nearest_permitted_hold(
2870 $self->editor, $copy, $self->editor->requestor, 1 # check_only
2872 return undef if ref $hold eq "HASH" and
2873 $hold->{"textcode"} eq "ACTION_HOLD_REQUEST_NOT_FOUND";
2877 sub reservation_capture_is_possible {
2879 my $copy = $self->copy;
2881 # we've been explicitly told not to capture any holds
2882 return 0 if $self->capture eq 'nocapture';
2884 my $booking_ses = OpenSRF::AppSession->connect("open-ils.booking");
2885 my $resv = $booking_ses->request(
2886 "open-ils.booking.reservations.could_capture",
2887 $self->editor->authtoken, $copy->barcode
2889 $booking_ses->disconnect;
2890 if (ref($resv) eq "HASH" and exists $resv->{"textcode"}) {
2891 $self->push_events($resv);
2897 # returns true if the item was used (or may potentially be used
2898 # in subsequent calls) to capture a hold.
2899 sub attempt_checkin_hold_capture {
2901 my $copy = $self->copy;
2903 # we've been explicitly told not to capture any holds
2904 return 0 if $self->capture eq 'nocapture';
2906 # See if this copy can fulfill any holds
2907 my ($hold, undef, $retarget) = $holdcode->find_nearest_permitted_hold(
2908 $self->editor, $copy, $self->editor->requestor );
2911 $logger->debug("circulator: no potential permitted".
2912 "holds found for copy ".$copy->barcode);
2916 if($self->capture ne 'capture') {
2917 # see if this item is in a hold-capture-delay location
2918 my $location = $self->copy->location;
2919 if(!ref($location)) {
2920 $location = $self->editor->retrieve_asset_copy_location($self->copy->location);
2921 $self->copy->location($location);
2923 if($U->is_true($location->hold_verify)) {
2924 $self->bail_on_events(
2925 OpenILS::Event->new('HOLD_CAPTURE_DELAYED', copy_location => $location));
2930 $self->retarget($retarget);
2932 my $suppress_transit = 0;
2933 if( $hold->pickup_lib != $self->circ_lib and not $self->hold_as_transit ) {
2934 my $suppress_transit_circ = $U->ou_ancestor_setting($self->circ_lib, 'circ.transit.suppress_hold');
2935 if($suppress_transit_circ && $suppress_transit_circ->{value}) {
2936 my $suppress_transit_pickup = $U->ou_ancestor_setting($hold->pickup_lib, 'circ.transit.suppress_hold');
2937 if($suppress_transit_pickup && $suppress_transit_circ->{value} eq $suppress_transit_pickup->{value}) {
2938 $suppress_transit = 1;
2939 $self->hold->pickup_lib($self->circ_lib);
2944 $logger->info("circulator: found permitted hold ".$hold->id." for copy, capturing...");
2946 $hold->current_copy($copy->id);
2947 $hold->capture_time('now');
2948 $self->put_hold_on_shelf($hold)
2949 if ($suppress_transit || ($hold->pickup_lib == $self->circ_lib and not $self->hold_as_transit) );
2951 # prevent DB errors caused by fetching
2952 # holds from storage, and updating through cstore
2953 $hold->clear_fulfillment_time;
2954 $hold->clear_fulfillment_staff;
2955 $hold->clear_fulfillment_lib;
2956 $hold->clear_expire_time;
2957 $hold->clear_cancel_time;
2958 $hold->clear_prev_check_time unless $hold->prev_check_time;
2960 $self->bail_on_events($self->editor->event)
2961 unless $self->editor->update_action_hold_request($hold);
2963 $self->checkin_changed(1);
2965 return 0 if $self->bail_out;
2967 if( $suppress_transit or ( $hold->pickup_lib == $self->circ_lib && not $self->hold_as_transit ) ) {
2969 if ($hold->hold_type eq 'R') {
2970 $copy->status(OILS_COPY_STATUS_CATALOGING);
2971 $hold->fulfillment_time('now');
2972 $self->noop(1); # Block other transit/hold checks
2973 $self->bail_on_events($self->editor->event)
2974 unless $self->editor->update_action_hold_request($hold);
2976 # This hold was captured in the correct location
2977 $copy->status(OILS_COPY_STATUS_ON_HOLDS_SHELF);
2978 $self->push_events(OpenILS::Event->new('SUCCESS'));
2980 #$self->do_hold_notify($hold->id);
2981 $self->notify_hold($hold->id);
2986 # Hold needs to be picked up elsewhere. Build a hold
2987 # transit and route the item.
2988 $self->checkin_build_hold_transit();
2989 $copy->status(OILS_COPY_STATUS_IN_TRANSIT);
2990 return 0 if $self->bail_out;
2991 $self->push_events(OpenILS::Event->new('ROUTE_ITEM', org => $hold->pickup_lib));
2994 # make sure we save the copy status
2996 return 0 if $copy->status == OILS_COPY_STATUS_CATALOGING;
3000 sub attempt_checkin_reservation_capture {
3002 my $copy = $self->copy;
3004 # we've been explicitly told not to capture any holds
3005 return 0 if $self->capture eq 'nocapture';
3007 my $booking_ses = OpenSRF::AppSession->connect("open-ils.booking");
3008 my $evt = $booking_ses->request(
3009 "open-ils.booking.resources.capture_for_reservation",
3010 $self->editor->authtoken,
3012 1 # don't update copy - we probably have it locked
3014 $booking_ses->disconnect;
3016 if (ref($evt) ne "HASH" or not exists $evt->{"textcode"}) {
3018 "open-ils.booking.resources.capture_for_reservation " .
3019 "didn't return an event!"
3023 $evt->{"textcode"} eq "RESERVATION_NOT_FOUND" and
3024 $evt->{"payload"}->{"fail_cause"} eq "not-transferable"
3026 # not-transferable is an error event we'll pass on the user
3027 $logger->warn("reservation capture attempted against non-transferable item");
3028 $self->push_events($evt);
3030 } elsif ($evt->{"textcode"} eq "SUCCESS") {
3031 # Re-retrieve copy as reservation capture may have changed
3032 # its status and whatnot.
3034 "circulator: booking capture win on copy " . $self->copy->id
3036 if (my $new_copy_status = $evt->{"payload"}->{"new_copy_status"}) {
3038 "circulator: changing copy " . $self->copy->id .
3039 "'s status from " . $self->copy->status . " to " .
3042 $self->copy->status($new_copy_status);
3045 $self->reservation($evt->{"payload"}->{"reservation"});
3047 if (exists $evt->{"payload"}->{"transit"}) {
3051 "org" => $evt->{"payload"}->{"transit"}->dest
3055 $self->checkin_changed(1);
3059 # other results are treated as "nothing to capture"
3063 sub do_hold_notify {
3064 my( $self, $holdid ) = @_;
3066 my $e = new_editor(xact => 1);
3067 my $hold = $e->retrieve_action_hold_request($holdid) or return $e->die_event;
3069 my $ses = OpenSRF::AppSession->create('open-ils.trigger');
3070 $ses->request('open-ils.trigger.event.autocreate', 'hold.available', $hold, $hold->pickup_lib);
3072 $logger->info("circulator: running delayed hold notify process");
3074 # my $notifier = OpenILS::Application::Circ::HoldNotify->new(
3075 # hold_id => $holdid, editor => new_editor(requestor=>$self->editor->requestor));
3077 my $notifier = OpenILS::Application::Circ::HoldNotify->new(
3078 hold_id => $holdid, requestor => $self->editor->requestor);
3080 $logger->debug("circulator: built hold notifier");
3082 if(!$notifier->event) {
3084 $logger->info("circulator: attempt at sending hold notification for hold $holdid");
3086 my $stat = $notifier->send_email_notify;
3087 if( $stat == '1' ) {
3088 $logger->info("circulator: hold notify succeeded for hold $holdid");
3092 $logger->debug("circulator: * hold notify cancelled or failed for hold $holdid");
3095 $logger->info("circulator: Not sending hold notification since the patron has no email address");
3099 sub retarget_holds {
3101 $logger->info("circulator: retargeting holds @{$self->retarget} after opportunistic capture");
3102 my $ses = OpenSRF::AppSession->create('open-ils.storage');
3103 $ses->request('open-ils.storage.action.hold_request.copy_targeter', undef, $self->retarget);
3104 # no reason to wait for the return value
3108 sub checkin_build_hold_transit {
3111 my $copy = $self->copy;
3112 my $hold = $self->hold;
3113 my $trans = Fieldmapper::action::hold_transit_copy->new;
3115 $logger->debug("circulator: building hold transit for ".$copy->barcode);
3117 $trans->hold($hold->id);
3118 $trans->source($self->circ_lib);
3119 $trans->dest($hold->pickup_lib);
3120 $trans->source_send_time("now");
3121 $trans->target_copy($copy->id);
3123 # when the copy gets to its destination, it will recover
3124 # this status - put it onto the holds shelf
3125 $trans->copy_status(OILS_COPY_STATUS_ON_HOLDS_SHELF);
3127 return $self->bail_on_events($self->editor->event)
3128 unless $self->editor->create_action_hold_transit_copy($trans);
3133 sub process_received_transit {
3135 my $copy = $self->copy;
3136 my $copyid = $self->copy->id;
3138 my $status_name = $U->copy_status($copy->status)->name;
3139 $logger->debug("circulator: attempting transit receive on ".
3140 "copy $copyid. Copy status is $status_name");
3142 my $transit = $self->transit;
3144 # Check if we are in a transit suppress range
3145 my $suppress_transit = 0;
3146 if ( $transit->dest != $self->circ_lib and not ( $self->hold_as_transit and $transit->copy_status == OILS_COPY_STATUS_ON_HOLDS_SHELF ) ) {
3147 my $suppress_setting = ($transit->copy_status == OILS_COPY_STATUS_ON_HOLDS_SHELF ? 'circ.transit.suppress_hold' : 'circ.transit.suppress_non_hold');
3148 my $suppress_transit_circ = $U->ou_ancestor_setting($self->circ_lib, $suppress_setting);
3149 if($suppress_transit_circ && $suppress_transit_circ->{value}) {
3150 my $suppress_transit_dest = $U->ou_ancestor_setting($transit->dest, $suppress_setting);
3151 if($suppress_transit_dest && $suppress_transit_dest->{value} eq $suppress_transit_circ->{value}) {
3152 $suppress_transit = 1;
3153 $self->fake_hold_dest(1) if $transit->copy_status == OILS_COPY_STATUS_ON_HOLDS_SHELF;
3157 if( not $suppress_transit and ( $transit->dest != $self->circ_lib or ($self->hold_as_transit && $transit->copy_status == OILS_COPY_STATUS_ON_HOLDS_SHELF) ) ) {
3158 # - this item is in-transit to a different location
3159 # - Or we are capturing holds as transits, so why create a new transit?
3161 my $tid = $transit->id;
3162 my $loc = $self->circ_lib;
3163 my $dest = $transit->dest;
3165 $logger->info("circulator: Fowarding transit on copy which is destined ".
3166 "for a different location. transit=$tid, copy=$copyid, current ".
3167 "location=$loc, destination location=$dest");
3169 my $evt = OpenILS::Event->new('ROUTE_ITEM', org => $dest, payload => {});
3171 # grab the associated hold object if available
3172 my $ht = $self->editor->retrieve_action_hold_transit_copy($tid);
3173 $self->hold($self->editor->retrieve_action_hold_request($ht->hold)) if $ht;
3175 return $self->bail_on_events($evt);
3178 # The transit is received, set the receive time
3179 $transit->dest_recv_time('now');
3180 $self->bail_on_events($self->editor->event)
3181 unless $self->editor->update_action_transit_copy($transit);
3183 my $hold_transit = $self->editor->retrieve_action_hold_transit_copy($transit->id);
3185 $logger->info("circulator: Recovering original copy status in transit: ".$transit->copy_status);
3186 $copy->status( $transit->copy_status );
3187 $self->update_copy();
3188 return if $self->bail_out;
3192 my $hold = $self->editor->retrieve_action_hold_request($hold_transit->hold);
3194 # hold has arrived at destination, set shelf time
3195 $self->put_hold_on_shelf($hold);
3196 $self->bail_on_events($self->editor->event)
3197 unless $self->editor->update_action_hold_request($hold);
3198 return if $self->bail_out;
3200 $self->notify_hold($hold_transit->hold);
3205 OpenILS::Event->new(
3208 payload => { transit => $transit, holdtransit => $hold_transit } ));
3210 return $hold_transit;
3214 # ------------------------------------------------------------------
3215 # Sets the shelf_time and shelf_expire_time for a newly shelved hold
3216 # ------------------------------------------------------------------
3217 sub put_hold_on_shelf {
3218 my($self, $hold) = @_;
3219 $hold->shelf_time('now');
3220 $hold->current_shelf_lib($self->circ_lib);
3221 $holdcode->set_hold_shelf_expire_time($hold, $self->editor);
3227 sub generate_fines {
3229 my $reservation = shift;
3231 $self->generate_fines_start($reservation);
3232 $self->generate_fines_finish($reservation);
3237 sub generate_fines_start {
3239 my $reservation = shift;
3240 my $dt_parser = DateTime::Format::ISO8601->new;
3242 my $obj = $reservation ? $self->reservation : $self->circ;
3244 # If we have a grace period
3245 if($obj->can('grace_period')) {
3246 # Parse out the due date
3247 my $due_date = $dt_parser->parse_datetime( cleanse_ISO8601($obj->due_date) );
3248 # Add the grace period to the due date
3249 $due_date->add(seconds => OpenSRF::Utils->interval_to_seconds($obj->grace_period));
3250 # Don't generate fines on circs still in grace period
3251 return undef if ($due_date > DateTime->now);
3254 if (!exists($self->{_gen_fines_req})) {
3255 $self->{_gen_fines_req} = OpenSRF::AppSession->create('open-ils.storage')
3257 'open-ils.storage.action.circulation.overdue.generate_fines',
3265 sub generate_fines_finish {
3267 my $reservation = shift;
3269 return undef unless $self->{_gen_fines_req};
3271 my $id = $reservation ? $self->reservation->id : $self->circ->id;
3273 $self->{_gen_fines_req}->wait_complete;
3274 delete($self->{_gen_fines_req});
3276 # refresh the circ in case the fine generator set the stop_fines field
3277 $self->reservation($self->editor->retrieve_booking_reservation($id)) if $reservation;
3278 $self->circ($self->editor->retrieve_action_circulation($id)) if !$reservation;
3283 sub checkin_handle_circ {
3285 my $circ = $self->circ;
3286 my $copy = $self->copy;
3290 $self->backdate($circ->xact_start) if $self->claims_never_checked_out;
3292 # backdate the circ if necessary
3293 if($self->backdate) {
3294 my $evt = $self->checkin_handle_backdate;
3295 return $self->bail_on_events($evt) if $evt;
3298 if(!$circ->stop_fines) {
3299 $circ->stop_fines(OILS_STOP_FINES_CHECKIN);
3300 $circ->stop_fines(OILS_STOP_FINES_RENEW) if $self->is_renewal;
3301 $circ->stop_fines(OILS_STOP_FINES_CLAIMS_NEVERCHECKEDOUT) if $self->claims_never_checked_out;
3302 $circ->stop_fines_time('now');
3303 $circ->stop_fines_time($self->backdate) if $self->backdate;
3306 # Set the checkin vars since we have the item
3307 $circ->checkin_time( ($self->backdate) ? $self->backdate : 'now' );
3309 # capture the true scan time for back-dated checkins
3310 $circ->checkin_scan_time('now');
3312 $circ->checkin_staff($self->editor->requestor->id);
3313 $circ->checkin_lib($self->circ_lib);
3314 $circ->checkin_workstation($self->editor->requestor->wsid);
3316 my $circ_lib = (ref $self->copy->circ_lib) ?
3317 $self->copy->circ_lib->id : $self->copy->circ_lib;
3318 my $stat = $U->copy_status($self->copy->status)->id;
3320 if ($stat == OILS_COPY_STATUS_LOST) {
3321 # we will now handle lost fines, but the copy will retain its 'lost'
3322 # status if it needs to transit home unless lost_immediately_available
3325 # if we decide to also delay fine handling until the item arrives home,
3326 # we will need to call lost fine handling code both when checking items
3327 # in and also when receiving transits
3328 $self->checkin_handle_lost($circ_lib);
3329 } elsif ($circ_lib != $self->circ_lib and $stat == OILS_COPY_STATUS_MISSING) {
3330 $logger->info("circulator: not updating copy status on checkin because copy is missing");
3332 $self->copy->status($U->copy_status(OILS_COPY_STATUS_RESHELVING));
3337 # see if there are any fines owed on this circ. if not, close it
3338 ($obt) = $U->fetch_mbts($circ->id, $self->editor);
3339 $circ->xact_finish('now') if( $obt and $obt->balance_owed == 0 );
3341 $logger->debug("circulator: ".$obt->balance_owed." is owed on this circulation");
3343 return $self->bail_on_events($self->editor->event)
3344 unless $self->editor->update_action_circulation($circ);
3350 # ------------------------------------------------------------------
3351 # See if we need to void billings for lost checkin
3352 # ------------------------------------------------------------------
3353 sub checkin_handle_lost {
3355 my $circ_lib = shift;
3356 my $circ = $self->circ;
3358 my $max_return = $U->ou_ancestor_setting_value(
3359 $circ_lib, OILS_SETTING_MAX_ACCEPT_RETURN_OF_LOST, $self->editor) || 0;
3364 my @tm = reverse($circ->due_date =~ /([\d\.]+)/og);
3365 $tm[5] -= 1 if $tm[5] > 0;
3366 my $due = timelocal(int($tm[1]), int($tm[2]), int($tm[3]), int($tm[4]), int($tm[5]), int($tm[6]));
3368 my $last_chance = OpenSRF::Utils->interval_to_seconds($max_return) + int($due);
3369 $logger->info("MAX OD: ".$max_return." DUEDATE: ".$circ->due_date." TODAY: ".$today." DUE: ".$due." LAST: ".$last_chance);
3371 $max_return = 0 if $today < $last_chance;
3374 if (!$max_return){ # there's either no max time to accept returns defined or we're within that time
3376 my $void_lost = $U->ou_ancestor_setting_value(
3377 $circ_lib, OILS_SETTING_VOID_LOST_ON_CHECKIN, $self->editor) || 0;
3378 my $void_lost_fee = $U->ou_ancestor_setting_value(
3379 $circ_lib, OILS_SETTING_VOID_LOST_PROCESS_FEE_ON_CHECKIN, $self->editor) || 0;
3380 my $restore_od = $U->ou_ancestor_setting_value(
3381 $circ_lib, OILS_SETTING_RESTORE_OVERDUE_ON_LOST_RETURN, $self->editor) || 0;
3382 $self->generate_lost_overdue(1) if $U->ou_ancestor_setting_value(
3383 $circ_lib, OILS_SETTING_GENERATE_OVERDUE_ON_LOST_RETURN, $self->editor);
3385 $self->checkin_handle_lost_now_found(3) if $void_lost;
3386 $self->checkin_handle_lost_now_found(4) if $void_lost_fee;
3387 $self->checkin_handle_lost_now_found_restore_od($circ_lib) if $restore_od && ! $self->void_overdues;
3390 if ($circ_lib != $self->circ_lib) {
3391 # if the item is not home, check to see if we want to retain the lost
3392 # status at this point in the process
3393 my $immediately_available = $U->ou_ancestor_setting_value($circ_lib, OILS_SETTING_LOST_IMMEDIATELY_AVAILABLE, $self->editor) || 0;
3395 if ($immediately_available) {
3396 # lost item status does not need to be retained, so give it a
3397 # reshelving status as if it were a normal checkin
3398 $self->copy->status($U->copy_status(OILS_COPY_STATUS_RESHELVING));
3401 $logger->info("circulator: not updating copy status on checkin because copy is lost");
3404 # lost item is home and processed, treat like a normal checkin from
3406 $self->copy->status($U->copy_status(OILS_COPY_STATUS_RESHELVING));
3412 sub checkin_handle_backdate {
3415 # ------------------------------------------------------------------
3416 # clean up the backdate for date comparison
3417 # XXX We are currently taking the due-time from the original due-date,
3418 # not the input. Do we need to do this? This certainly interferes with
3419 # backdating of hourly checkouts, but that is likely a very rare case.
3420 # ------------------------------------------------------------------
3421 my $bd = cleanse_ISO8601($self->backdate);
3422 my $original_date = DateTime::Format::ISO8601->new->parse_datetime(cleanse_ISO8601($self->circ->due_date));
3423 my $new_date = DateTime::Format::ISO8601->new->parse_datetime($bd);
3424 $bd = cleanse_ISO8601($new_date->ymd . 'T' . $original_date->strftime('%T%z'));
3426 $self->backdate($bd);
3431 sub check_checkin_copy_status {
3433 my $copy = $self->copy;
3435 my $status = $U->copy_status($copy->status)->id;
3438 if( $status == OILS_COPY_STATUS_AVAILABLE ||
3439 $status == OILS_COPY_STATUS_CHECKED_OUT ||
3440 $status == OILS_COPY_STATUS_IN_PROCESS ||
3441 $status == OILS_COPY_STATUS_ON_HOLDS_SHELF ||
3442 $status == OILS_COPY_STATUS_IN_TRANSIT ||
3443 $status == OILS_COPY_STATUS_CATALOGING ||
3444 $status == OILS_COPY_STATUS_ON_RESV_SHELF ||
3445 $status == OILS_COPY_STATUS_RESHELVING );
3447 return OpenILS::Event->new('COPY_STATUS_LOST', payload => $copy )
3448 if( $status == OILS_COPY_STATUS_LOST );
3450 return OpenILS::Event->new('COPY_STATUS_MISSING', payload => $copy )
3451 if( $status == OILS_COPY_STATUS_MISSING );
3453 return OpenILS::Event->new('COPY_BAD_STATUS', payload => $copy );
3458 # --------------------------------------------------------------------------
3459 # On checkin, we need to return as many relevant objects as we can
3460 # --------------------------------------------------------------------------
3461 sub checkin_flesh_events {
3464 if( grep { $_->{textcode} eq 'SUCCESS' } @{$self->events}
3465 and grep { $_->{textcode} eq 'ITEM_NOT_CATALOGED' } @{$self->events} ) {
3466 $self->events([grep { $_->{textcode} eq 'ITEM_NOT_CATALOGED' } @{$self->events}]);
3469 my $record = $U->record_to_mvr($self->title) if($self->title and !$self->is_precat);
3472 if($self->hold and !$self->hold->cancel_time) {
3473 $hold = $self->hold;
3474 $hold->notes($self->editor->search_action_hold_request_note({hold => $hold->id}));
3478 # if we checked in a circulation, flesh the billing summary data
3479 $self->circ->billable_transaction(
3480 $self->editor->retrieve_money_billable_transaction([
3482 {flesh => 1, flesh_fields => {mbt => ['summary']}}
3488 # flesh some patron fields before returning
3490 $self->editor->retrieve_actor_user([
3495 au => ['card', 'billing_address', 'mailing_address']
3502 for my $evt (@{$self->events}) {
3505 $payload->{copy} = $U->unflesh_copy($self->copy);
3506 $payload->{volume} = $self->volume;
3507 $payload->{record} = $record,
3508 $payload->{circ} = $self->circ;
3509 $payload->{transit} = $self->transit;
3510 $payload->{cancelled_hold_transit} = 1 if $self->cancelled_hold_transit;
3511 $payload->{hold} = $hold;
3512 $payload->{patron} = $self->patron;
3513 $payload->{reservation} = $self->reservation
3514 unless (not $self->reservation or $self->reservation->cancel_time);
3516 $evt->{payload} = $payload;
3521 my( $self, $msg ) = @_;
3522 my $bc = ($self->copy) ? $self->copy->barcode :
3525 my $usr = ($self->patron) ? $self->patron->id : "";
3526 $logger->info("circulator: $msg requestor=".$self->editor->requestor->id.
3527 ", recipient=$usr, copy=$bc");
3533 $self->log_me("do_renew()");
3535 # Make sure there is an open circ to renew that is not
3536 # marked as LOST, CLAIMSRETURNED, or LONGOVERDUE
3537 my $usrid = $self->patron->id if $self->patron;
3538 my $circ = $self->editor->search_action_circulation({
3539 target_copy => $self->copy->id,
3540 xact_finish => undef,
3541 checkin_time => undef,
3542 ($usrid ? (usr => $usrid) : ()),
3544 {stop_fines => undef},
3545 {stop_fines => OILS_STOP_FINES_MAX_FINES}
3549 return $self->bail_on_events($self->editor->event) unless $circ;
3551 # A user is not allowed to renew another user's items without permission
3552 unless( $circ->usr eq $self->editor->requestor->id ) {
3553 return $self->bail_on_events($self->editor->events)
3554 unless $self->editor->allowed('RENEW_CIRC', $circ->circ_lib);
3557 $self->push_events(OpenILS::Event->new('MAX_RENEWALS_REACHED'))
3558 if $circ->renewal_remaining < 1;
3560 # -----------------------------------------------------------------
3562 $self->parent_circ($circ->id);
3563 $self->renewal_remaining( $circ->renewal_remaining - 1 );
3566 # Opac renewal - re-use circ library from original circ (unless told not to)
3567 if($self->opac_renewal) {
3568 unless(defined($opac_renewal_use_circ_lib)) {
3569 my $use_circ_lib = $self->editor->retrieve_config_global_flag('circ.opac_renewal.use_original_circ_lib');
3570 if($use_circ_lib and $U->is_true($use_circ_lib->enabled)) {
3571 $opac_renewal_use_circ_lib = 1;
3574 $opac_renewal_use_circ_lib = 0;
3577 $self->circ_lib($circ->circ_lib) if($opac_renewal_use_circ_lib);
3580 # Run the fine generator against the old circ
3581 $self->generate_fines_start;
3583 $self->run_renew_permit;
3586 $self->do_checkin();
3587 return if $self->bail_out;
3589 unless( $self->permit_override ) {
3591 return if $self->bail_out;
3592 $self->is_precat(1) if $self->have_event('ITEM_NOT_CATALOGED');
3593 $self->remove_event('ITEM_NOT_CATALOGED');
3596 $self->override_events;
3597 return if $self->bail_out;
3600 $self->do_checkout();
3605 my( $self, $evt ) = @_;
3606 $evt = (ref $evt) ? $evt->{textcode} : $evt;
3607 $logger->debug("circulator: removing event from list: $evt");
3608 my @events = @{$self->events};
3609 $self->events( [ grep { $_->{textcode} ne $evt } @events ] );
3614 my( $self, $evt ) = @_;
3615 $evt = (ref $evt) ? $evt->{textcode} : $evt;
3616 return grep { $_->{textcode} eq $evt } @{$self->events};
3621 sub run_renew_permit {
3624 if ($U->ou_ancestor_setting_value($self->circ_lib, 'circ.block_renews_for_holds')) {
3625 my ($hold, undef, $retarget) = $holdcode->find_nearest_permitted_hold(
3626 $self->editor, $self->copy, $self->editor->requestor, 1
3628 $self->push_events(new OpenILS::Event("COPY_NEEDED_FOR_HOLD")) if $hold;
3631 if(!$self->legacy_script_support) {
3632 my $results = $self->run_indb_circ_test;
3633 $self->push_events($self->matrix_test_result_events)
3634 unless $self->circ_test_success;
3637 my $runner = $self->script_runner;
3639 $runner->load($self->circ_permit_renew);
3640 my $result = $runner->run or
3641 throw OpenSRF::EX::ERROR ("Circ Permit Renew Script Died: $@");
3642 if ($result->{"events"}) {
3644 map { new OpenILS::Event($_) } @{$result->{"events"}}
3647 "circulator: circ_permit_renew for user " .
3648 $self->patron->id . " returned " .
3649 scalar(@{$result->{"events"}}) . " event(s)"
3653 $self->mk_script_runner;
3656 $logger->debug("circulator: re-creating script runner to be safe");
3660 # XXX: The primary mechanism for storing circ history is now handled
3661 # by tracking real circulation objects instead of bibs in a bucket.
3662 # However, this code is disabled by default and could be useful
3663 # some day, so may as well leave it for now.
3664 sub append_reading_list {
3668 $self->is_checkout and
3674 # verify history is globally enabled and uses the bucket mechanism
3675 my $htype = OpenSRF::Utils::SettingsClient->new->config_value(
3676 apps => 'open-ils.circ' => app_settings => 'checkout_history_mechanism');
3678 return undef unless $htype and $htype eq 'bucket';
3680 my $e = new_editor(xact => 1, requestor => $self->editor->requestor);
3682 # verify the patron wants to retain the hisory
3683 my $setting = $e->search_actor_user_setting(
3684 {usr => $self->patron->id, name => 'circ.keep_checkout_history'})->[0];
3686 unless($setting and $setting->value) {
3691 my $bkt = $e->search_container_copy_bucket(
3692 {owner => $self->patron->id, btype => 'circ_history'})->[0];
3697 # find the next item position
3698 my $last_item = $e->search_container_copy_bucket_item(
3699 {bucket => $bkt->id}, {order_by => {ccbi => 'pos desc'}, limit => 1})->[0];
3700 $pos = $last_item->pos + 1 if $last_item;
3703 # create the history bucket if necessary
3704 $bkt = Fieldmapper::container::copy_bucket->new;
3705 $bkt->owner($self->patron->id);
3707 $bkt->btype('circ_history');
3709 $e->create_container_copy_bucket($bkt) or return $e->die_event;
3712 my $item = Fieldmapper::container::copy_bucket_item->new;
3714 $item->bucket($bkt->id);
3715 $item->target_copy($self->copy->id);
3718 $e->create_container_copy_bucket_item($item) or return $e->die_event;
3725 sub make_trigger_events {
3727 return unless $self->circ;
3728 $U->create_events_for_hook('checkout', $self->circ, $self->circ_lib) if $self->is_checkout;
3729 $U->create_events_for_hook('checkin', $self->circ, $self->circ_lib) if $self->is_checkin;
3730 $U->create_events_for_hook('renewal', $self->circ, $self->circ_lib) if $self->is_renewal;
3735 sub checkin_handle_lost_now_found {
3736 my ($self, $bill_type) = @_;
3738 # ------------------------------------------------------------------
3739 # remove charge from patron's account if lost item is returned
3740 # ------------------------------------------------------------------
3742 my $bills = $self->editor->search_money_billing(
3744 xact => $self->circ->id,
3749 $logger->debug("voiding lost item charge of ".scalar(@$bills));
3750 for my $bill (@$bills) {
3751 if( !$U->is_true($bill->voided) ) {
3752 $logger->info("lost item returned - voiding bill ".$bill->id);
3754 $bill->void_time('now');
3755 $bill->voider($self->editor->requestor->id);
3756 my $note = ($bill->note) ? $bill->note . "\n" : '';
3757 $bill->note("${note}System: VOIDED FOR LOST ITEM RETURNED");
3759 $self->bail_on_events($self->editor->event)
3760 unless $self->editor->update_money_billing($bill);
3765 sub checkin_handle_lost_now_found_restore_od {
3767 my $circ_lib = shift;
3769 # ------------------------------------------------------------------
3770 # restore those overdue charges voided when item was set to lost
3771 # ------------------------------------------------------------------
3773 my $ods = $self->editor->search_money_billing(
3775 xact => $self->circ->id,
3780 $logger->debug("returning overdue charges pre-lost ".scalar(@$ods));
3781 for my $bill (@$ods) {
3782 if( $U->is_true($bill->voided) ) {
3783 $logger->info("lost item returned - restoring overdue ".$bill->id);
3785 $bill->clear_void_time;
3786 $bill->voider($self->editor->requestor->id);
3787 my $note = ($bill->note) ? $bill->note . "\n" : '';
3788 $bill->note("${note}System: LOST RETURNED - OVERDUES REINSTATED");
3790 $self->bail_on_events($self->editor->event)
3791 unless $self->editor->update_money_billing($bill);
3796 # ------------------------------------------------------------------
3797 # Lost-then-found item checked in. This sub generates new overdue
3798 # fines, beyond the point of any existing and possibly voided
3799 # overdue fines, up to the point of final checkin time (or max fine
3801 # ------------------------------------------------------------------
3802 sub generate_lost_overdue_fines {
3804 my $circ = $self->circ;
3805 my $e = $self->editor;
3807 # Re-open the transaction so the fine generator can see it
3808 if($circ->xact_finish or $circ->stop_fines) {
3810 $circ->clear_xact_finish;
3811 $circ->clear_stop_fines;
3812 $circ->clear_stop_fines_time;
3813 $e->update_action_circulation($circ) or return $e->die_event;
3817 $e->xact_begin; # generate_fines expects an in-xact editor
3818 $self->generate_fines;
3819 $circ = $self->circ; # generate fines re-fetches the circ
3823 # Re-close the transaction if no money is owed
3824 my ($obt) = $U->fetch_mbts($circ->id, $e);
3825 if ($obt and $obt->balance_owed == 0) {
3826 $circ->xact_finish('now');
3830 # Set stop fines if the fine generator didn't have to
3831 unless($circ->stop_fines) {
3832 $circ->stop_fines(OILS_STOP_FINES_CHECKIN);
3833 $circ->stop_fines_time('now');
3837 # update the event data sent to the caller within the transaction
3838 $self->checkin_flesh_events;
3841 $e->update_action_circulation($circ) or return $e->die_event;