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
543 my $type = ref($self) or die "$self is not an object";
545 my $name = $AUTOLOAD;
548 unless (grep { $_ eq $name } @AUTOLOAD_FIELDS) {
549 $logger->error("circulator: $type: invalid autoload field: $name");
550 die "$type: invalid autoload field: $name\n"
555 *{"${type}::${name}"} = sub {
558 $s->{$name} = $v if defined $v;
562 return $self->$name($data);
567 my( $class, $auth, %args ) = @_;
568 $class = ref($class) || $class;
569 my $self = bless( {}, $class );
572 $self->editor(new_editor(xact => 1, authtoken => $auth));
574 unless( $self->editor->checkauth ) {
575 $self->bail_on_events($self->editor->event);
579 $self->cache_handle(OpenSRF::Utils::Cache->new('global'));
581 $self->$_($args{$_}) for keys %args;
584 ($self->circ_lib) ? $self->circ_lib : $self->editor->requestor->ws_ou);
586 # if this is a renewal, default to desk_renewal
587 $self->desk_renewal(1) unless
588 $self->opac_renewal or $self->phone_renewal or $self->sip_renewal;
590 $self->capture('') unless $self->capture;
592 unless(%user_groups) {
593 my $gps = $self->editor->retrieve_all_permission_grp_tree;
594 %user_groups = map { $_->id => $_ } @$gps;
601 # --------------------------------------------------------------------------
602 # True if we should discontinue processing
603 # --------------------------------------------------------------------------
605 my( $self, $bool ) = @_;
606 if( defined $bool ) {
607 $logger->info("circulator: BAILING OUT") if $bool;
608 $self->{bail_out} = $bool;
610 return $self->{bail_out};
615 my( $self, @evts ) = @_;
618 $e->{payload} = $self->copy if
619 ($e->{textcode} eq 'COPY_NOT_AVAILABLE');
621 $logger->info("circulator: pushing event ".$e->{textcode});
622 push( @{$self->events}, $e ) unless
623 grep { $_->{textcode} eq $e->{textcode} } @{$self->events};
629 return '' if $self->skip_permit_key;
630 my $key = md5_hex( time() . rand() . "$$" );
631 $self->cache_handle->put_cache( "oils_permit_key_$key", 1, 300 );
632 return $self->permit_key($key);
635 sub check_permit_key {
637 return 1 if $self->skip_permit_key;
638 my $key = $self->permit_key;
639 return 0 unless $key;
640 my $k = "oils_permit_key_$key";
641 my $one = $self->cache_handle->get_cache($k);
642 $self->cache_handle->delete_cache($k);
643 return ($one) ? 1 : 0;
646 sub seems_like_reservation {
649 # Some words about the following method:
650 # 1) It requires the VIEW_USER permission, but that's not an
651 # issue, right, since all staff should have that?
652 # 2) It returns only one reservation at a time, even if an item can be
653 # and is currently overbooked. Hmmm....
654 my $booking_ses = create OpenSRF::AppSession("open-ils.booking");
655 my $result = $booking_ses->request(
656 "open-ils.booking.reservations.by_returnable_resource_barcode",
657 $self->editor->authtoken,
660 $booking_ses->disconnect;
662 return $self->bail_on_events($result) if defined $U->event_code($result);
665 $self->reservation(shift @$result);
673 # save_trimmed_copy() used just to be a block in mk_env(), but was separated for re-use
674 sub save_trimmed_copy {
675 my ($self, $copy) = @_;
678 $self->volume($copy->call_number);
679 $self->title($self->volume->record);
680 $self->copy->call_number($self->volume->id);
681 $self->volume->record($self->title->id);
682 $self->is_precat(1) if $self->volume->id == OILS_PRECAT_CALL_NUMBER;
683 if($self->copy->deposit_amount and $self->copy->deposit_amount > 0) {
684 $self->is_deposit(1) if $U->is_true($self->copy->deposit);
685 $self->is_rental(1) unless $U->is_true($self->copy->deposit);
691 my $e = $self->editor;
693 # --------------------------------------------------------------------------
694 # Grab the fleshed copy
695 # --------------------------------------------------------------------------
696 unless($self->is_noncat) {
699 $copy = $e->retrieve_asset_copy(
700 [$self->copy_id, $MK_ENV_FLESH ]) or return $e->event;
702 } elsif( $self->copy_barcode ) {
704 $copy = $e->search_asset_copy(
705 [{barcode => $self->copy_barcode, deleted => 'f'}, $MK_ENV_FLESH ])->[0];
706 } elsif( $self->reservation ) {
707 my $res = $e->json_query(
709 "select" => {"acp" => ["id"]},
714 "field" => "barcode",
718 "field" => "current_resource"
726 "id" => (ref $self->reservation) ?
727 $self->reservation->id : $self->reservation
732 if (ref $res eq "ARRAY" and scalar @$res) {
733 $logger->info("circulator: mapped reservation " .
734 $self->reservation . " to copy " . $res->[0]->{"id"});
735 $copy = $e->retrieve_asset_copy([$res->[0]->{"id"}, $MK_ENV_FLESH]);
740 $self->save_trimmed_copy($copy);
742 # We can't renew if there is no copy
743 return $self->bail_on_events(OpenILS::Event->new('ASSET_COPY_NOT_FOUND'))
744 if $self->is_renewal;
749 # --------------------------------------------------------------------------
751 # --------------------------------------------------------------------------
755 flesh_fields => {au => [ qw/ card / ]}
758 if( $self->patron_id ) {
759 $patron = $e->retrieve_actor_user([$self->patron_id, $flesh])
760 or return $self->bail_on_events(OpenILS::Event->new('ACTOR_USER_NOT_FOUND'));
762 } elsif( $self->patron_barcode ) {
764 # note: throwing ACTOR_USER_NOT_FOUND instead of ACTOR_CARD_NOT_FOUND is intentional
765 my $card = $e->search_actor_card({barcode => $self->patron_barcode})->[0]
766 or return $self->bail_on_events(OpenILS::Event->new('ACTOR_USER_NOT_FOUND'));
768 $patron = $e->retrieve_actor_user($card->usr)
769 or return $self->bail_on_events(OpenILS::Event->new('ACTOR_USER_NOT_FOUND'));
771 # Use the card we looked up, not the patron's primary, for card active checks
772 $patron->card($card);
775 if( my $copy = $self->copy ) {
778 $flesh->{flesh_fields}->{circ} = ['usr'];
780 my $circ = $e->search_action_circulation([
781 {target_copy => $copy->id, checkin_time => undef}, $flesh
785 $patron = $circ->usr;
786 $circ->usr($patron->id); # de-flesh for consistency
792 return $self->bail_on_events(OpenILS::Event->new('ACTOR_USER_NOT_FOUND'))
793 unless $self->patron($patron) or $self->is_checkin;
795 unless($self->is_checkin) {
797 # Check for inactivity and patron reg. expiration
799 $self->bail_on_events(OpenILS::Event->new('PATRON_INACTIVE'))
800 unless $U->is_true($patron->active);
802 $self->bail_on_events(OpenILS::Event->new('PATRON_CARD_INACTIVE'))
803 unless $U->is_true($patron->card->active);
805 my $expire = DateTime::Format::ISO8601->new->parse_datetime(
806 cleanse_ISO8601($patron->expire_date));
808 $self->bail_on_events(OpenILS::Event->new('PATRON_ACCOUNT_EXPIRED'))
809 if( CORE::time > $expire->epoch ) ;
813 # --------------------------------------------------------------------------
814 # This builds the script runner environment and fetches most of the
816 # --------------------------------------------------------------------------
817 sub mk_script_runner {
823 qw/copy copy_barcode copy_id patron
824 patron_id patron_barcode volume title editor/;
826 # Translate our objects into the ScriptBuilder args hash
827 $$args{$_} = $self->$_() for @fields;
829 $args->{ignore_user_status} = 1 if $self->is_checkin;
830 $$args{fetch_patron_by_circ_copy} = 1;
831 $$args{fetch_patron_circ_info} = 1 unless $self->is_checkin;
833 if( my $pco = $self->pending_checkouts ) {
834 $logger->info("circulator: we were given a pending checkouts number of $pco");
835 $$args{patronItemsOut} = $pco;
838 # This fetches most of the objects we need
839 $self->script_runner(
840 OpenILS::Application::Circ::ScriptBuilder->build($args));
842 # Now we translate the ScriptBuilder objects back into self
843 $self->$_($$args{$_}) for @fields;
845 my @evts = @{$args->{_events}} if $args->{_events};
847 $logger->debug("circulator: script builder returned events: @evts") if @evts;
851 # Anything besides ASSET_COPY_NOT_FOUND will stop processing
852 if(!$self->is_noncat and
854 $evts[0]->{textcode} eq 'ASSET_COPY_NOT_FOUND') {
858 my @e = grep { $_->{textcode} ne 'ASSET_COPY_NOT_FOUND' } @evts;
859 return $self->bail_on_events(@e);
864 $self->is_precat(1) if $self->copy->call_number == OILS_PRECAT_CALL_NUMBER;
865 if($self->copy->deposit_amount and $self->copy->deposit_amount > 0) {
866 $self->is_deposit(1) if $U->is_true($self->copy->deposit);
867 $self->is_rental(1) unless $U->is_true($self->copy->deposit);
871 # We can't renew if there is no copy
872 return $self->bail_on_events(@evts) if
873 $self->is_renewal and !$self->copy;
875 # Set some circ-specific flags in the script environment
876 my $evt = "environment";
877 $self->script_runner->insert("$evt.isRenewal", ($self->is_renewal) ? 1 : undef);
879 if( $self->is_noncat ) {
880 $self->script_runner->insert("$evt.isNonCat", 1);
881 $self->script_runner->insert("$evt.nonCatType", $self->noncat_type);
884 if( $self->is_precat ) {
885 $self->script_runner->insert("environment.isPrecat", 1, 1);
888 $self->script_runner->add_path( $_ ) for @$script_libs;
893 # --------------------------------------------------------------------------
894 # Does the circ permit work
895 # --------------------------------------------------------------------------
899 $self->log_me("do_permit()");
901 unless( $self->editor->requestor->id == $self->patron->id ) {
902 return $self->bail_on_events($self->editor->event)
903 unless( $self->editor->allowed('VIEW_PERMIT_CHECKOUT') );
906 $self->check_captured_holds();
907 $self->do_copy_checks();
908 return if $self->bail_out;
909 $self->run_patron_permit_scripts();
910 $self->run_copy_permit_scripts()
911 unless $self->is_precat or $self->is_noncat;
912 $self->check_item_deposit_events();
913 $self->override_events();
914 return if $self->bail_out;
916 if($self->is_precat and not $self->request_precat) {
919 'ITEM_NOT_CATALOGED', payload => $self->mk_permit_key));
920 return $self->bail_out(1) unless $self->is_renewal;
924 OpenILS::Event->new('SUCCESS', payload => $self->mk_permit_key));
927 sub check_item_deposit_events {
929 $self->push_events(OpenILS::Event->new('ITEM_DEPOSIT_REQUIRED', payload => $self->copy))
930 if $self->is_deposit and not $self->is_deposit_exempt;
931 $self->push_events(OpenILS::Event->new('ITEM_RENTAL_FEE_REQUIRED', payload => $self->copy))
932 if $self->is_rental and not $self->is_rental_exempt;
935 # returns true if the user is not required to pay deposits
936 sub is_deposit_exempt {
938 my $pid = (ref $self->patron->profile) ?
939 $self->patron->profile->id : $self->patron->profile;
940 my $groups = $U->ou_ancestor_setting_value(
941 $self->circ_lib, 'circ.deposit.exempt_groups', $self->editor);
942 for my $grp (@$groups) {
943 return 1 if $self->is_group_descendant($grp, $pid);
948 # returns true if the user is not required to pay rental fees
949 sub is_rental_exempt {
951 my $pid = (ref $self->patron->profile) ?
952 $self->patron->profile->id : $self->patron->profile;
953 my $groups = $U->ou_ancestor_setting_value(
954 $self->circ_lib, 'circ.rental.exempt_groups', $self->editor);
955 for my $grp (@$groups) {
956 return 1 if $self->is_group_descendant($grp, $pid);
961 sub is_group_descendant {
962 my($self, $p_id, $c_id) = @_;
963 return 0 unless defined $p_id and defined $c_id;
964 return 1 if $c_id == $p_id;
965 while(my $grp = $user_groups{$c_id}) {
966 $c_id = $grp->parent;
967 return 0 unless defined $c_id;
968 return 1 if $c_id == $p_id;
973 sub check_captured_holds {
975 my $copy = $self->copy;
976 my $patron = $self->patron;
978 return undef unless $copy;
980 my $s = $U->copy_status($copy->status)->id;
981 return unless $s == OILS_COPY_STATUS_ON_HOLDS_SHELF;
982 $logger->info("circulator: copy is on holds shelf, searching for the correct hold");
984 # Item is on the holds shelf, make sure it's going to the right person
985 my $holds = $self->editor->search_action_hold_request(
988 current_copy => $copy->id ,
989 capture_time => { '!=' => undef },
990 cancel_time => undef,
991 fulfillment_time => undef
997 if( $holds and $$holds[0] ) {
998 return undef if $$holds[0]->usr == $patron->id;
1001 $logger->info("circulator: this copy is needed by a different patron to fulfill a hold");
1003 $self->push_events(OpenILS::Event->new('ITEM_ON_HOLDS_SHELF'));
1007 sub do_copy_checks {
1009 my $copy = $self->copy;
1010 return unless $copy;
1012 my $stat = $U->copy_status($copy->status)->id;
1014 # We cannot check out a copy if it is in-transit
1015 if( $stat == OILS_COPY_STATUS_IN_TRANSIT ) {
1016 return $self->bail_on_events(OpenILS::Event->new('COPY_IN_TRANSIT'));
1019 $self->handle_claims_returned();
1020 return if $self->bail_out;
1022 # no claims returned circ was found, check if there is any open circ
1023 unless( $self->is_renewal ) {
1025 my $circs = $self->editor->search_action_circulation(
1026 { target_copy => $copy->id, checkin_time => undef }
1029 if(my $old_circ = $circs->[0]) { # an open circ was found
1031 my $payload = {copy => $copy};
1033 if($old_circ->usr == $self->patron->id) {
1035 $payload->{old_circ} = $old_circ;
1037 # If there is an open circulation on the checkout item and an auto-renew
1038 # interval is defined, inform the caller that they should go
1039 # ahead and renew the item instead of warning about open circulations.
1041 my $auto_renew_intvl = $U->ou_ancestor_setting_value(
1043 'circ.checkout_auto_renew_age',
1047 if($auto_renew_intvl) {
1048 my $intvl_seconds = OpenSRF::Utils->interval_to_seconds($auto_renew_intvl);
1049 my $checkout_time = DateTime::Format::ISO8601->new->parse_datetime( cleanse_ISO8601($old_circ->xact_start) );
1051 if(DateTime->now > $checkout_time->add(seconds => $intvl_seconds)) {
1052 $payload->{auto_renew} = 1;
1057 return $self->bail_on_events(
1058 OpenILS::Event->new('OPEN_CIRCULATION_EXISTS', payload => $payload)
1064 my $LEGACY_CIRC_EVENT_MAP = {
1065 'no_item' => 'ITEM_NOT_CATALOGED',
1066 'actor.usr.barred' => 'PATRON_BARRED',
1067 'asset.copy.circulate' => 'COPY_CIRC_NOT_ALLOWED',
1068 'asset.copy.status' => 'COPY_NOT_AVAILABLE',
1069 'asset.copy_location.circulate' => 'COPY_CIRC_NOT_ALLOWED',
1070 'config.circ_matrix_test.circulate' => 'COPY_CIRC_NOT_ALLOWED',
1071 'config.circ_matrix_test.max_items_out' => 'PATRON_EXCEEDS_CHECKOUT_COUNT',
1072 'config.circ_matrix_test.max_overdue' => 'PATRON_EXCEEDS_OVERDUE_COUNT',
1073 'config.circ_matrix_test.max_fines' => 'PATRON_EXCEEDS_FINES',
1074 'config.circ_matrix_circ_mod_test' => 'PATRON_EXCEEDS_CHECKOUT_COUNT',
1078 # ---------------------------------------------------------------------
1079 # This pushes any patron-related events into the list but does not
1080 # set bail_out for any events
1081 # ---------------------------------------------------------------------
1082 sub run_patron_permit_scripts {
1084 my $runner = $self->script_runner;
1085 my $patronid = $self->patron->id;
1089 if(!$self->legacy_script_support) {
1091 my $results = $self->run_indb_circ_test;
1092 unless($self->circ_test_success) {
1093 # no_item result is OK during noncat checkout
1094 unless(@$results == 1 && $results->[0]->{fail_part} eq 'no_item' and $self->is_noncat) {
1095 push @allevents, $self->matrix_test_result_events;
1101 # ---------------------------------------------------------------------
1102 # # Now run the patron permit script
1103 # ---------------------------------------------------------------------
1104 $runner->load($self->circ_permit_patron);
1105 my $result = $runner->run or
1106 throw OpenSRF::EX::ERROR ("Circ Permit Patron Script Died: $@");
1108 my $patron_events = $result->{events};
1110 OpenILS::Utils::Penalty->calculate_penalties($self->editor, $self->patron->id, $self->circ_lib);
1111 my $mask = ($self->is_renewal) ? 'RENEW' : 'CIRC';
1112 my $penalties = OpenILS::Utils::Penalty->retrieve_penalties($self->editor, $patronid, $self->circ_lib, $mask);
1113 $penalties = $penalties->{fatal_penalties};
1115 for my $pen (@$penalties) {
1116 my $event = OpenILS::Event->new($pen->name);
1117 $event->{desc} = $pen->label;
1118 push(@allevents, $event);
1121 push(@allevents, OpenILS::Event->new($_)) for (@$patron_events);
1125 $_->{payload} = $self->copy if
1126 ($_->{textcode} eq 'COPY_NOT_AVAILABLE');
1129 $logger->info("circulator: permit_patron script returned events: @allevents") if @allevents;
1131 $self->push_events(@allevents);
1134 sub matrix_test_result_codes {
1136 map { $_->{"fail_part"} } @{$self->matrix_test_result};
1139 sub matrix_test_result_events {
1142 my $event = new OpenILS::Event(
1143 $LEGACY_CIRC_EVENT_MAP->{$_->{"fail_part"}} || $_->{"fail_part"}
1145 $event->{"payload"} = {"fail_part" => $_->{"fail_part"}};
1147 } (@{$self->matrix_test_result});
1150 sub run_indb_circ_test {
1152 return $self->matrix_test_result if $self->matrix_test_result;
1154 my $dbfunc = ($self->is_renewal) ?
1155 'action.item_user_renew_test' : 'action.item_user_circ_test';
1157 if( $self->is_precat && $self->request_precat) {
1158 $self->make_precat_copy;
1159 return if $self->bail_out;
1162 my $results = $self->editor->json_query(
1166 ($self->is_noncat or ($self->is_precat and !$self->override and !$self->is_renewal)) ? undef : $self->copy->id,
1172 $self->circ_test_success($U->is_true($results->[0]->{success}));
1174 if(my $mp = $results->[0]->{matchpoint}) {
1175 $logger->info("circulator: circ policy test found matchpoint built via rows " . $results->[0]->{buildrows});
1176 $self->circ_matrix_matchpoint($self->editor->retrieve_config_circ_matrix_matchpoint($mp));
1177 $self->circ_matrix_matchpoint->duration_rule($self->editor->retrieve_config_rules_circ_duration($results->[0]->{duration_rule}));
1178 if(defined($results->[0]->{renewals})) {
1179 $self->circ_matrix_matchpoint->duration_rule->max_renewals($results->[0]->{renewals});
1181 $self->circ_matrix_matchpoint->recurring_fine_rule($self->editor->retrieve_config_rules_recurring_fine($results->[0]->{recurring_fine_rule}));
1182 if(defined($results->[0]->{grace_period})) {
1183 $self->circ_matrix_matchpoint->recurring_fine_rule->grace_period($results->[0]->{grace_period});
1185 $self->circ_matrix_matchpoint->max_fine_rule($self->editor->retrieve_config_rules_max_fine($results->[0]->{max_fine_rule}));
1186 $self->circ_matrix_matchpoint->hard_due_date($self->editor->retrieve_config_hard_due_date($results->[0]->{hard_due_date}));
1189 return $self->matrix_test_result($results);
1192 # ---------------------------------------------------------------------
1193 # given a use and copy, this will calculate the circulation policy
1194 # parameters. Only works with in-db circ.
1195 # ---------------------------------------------------------------------
1199 return OpenILS::Event->new('ASSET_COPY_NOT_FOUND') unless $self->copy;
1201 $self->run_indb_circ_test;
1204 circ_test_success => $self->circ_test_success,
1205 failure_events => [],
1206 failure_codes => [],
1207 matchpoint => $self->circ_matrix_matchpoint
1210 unless($self->circ_test_success) {
1211 $results->{"failure_codes"} = [ $self->matrix_test_result_codes ];
1212 $results->{"failure_events"} = [ $self->matrix_test_result_events ];
1215 if($self->circ_matrix_matchpoint) {
1216 my $duration_rule = $self->circ_matrix_matchpoint->duration_rule;
1217 my $recurring_fine_rule = $self->circ_matrix_matchpoint->recurring_fine_rule;
1218 my $max_fine_rule = $self->circ_matrix_matchpoint->max_fine_rule;
1219 my $hard_due_date = $self->circ_matrix_matchpoint->hard_due_date;
1221 my $policy = $self->get_circ_policy(
1222 $duration_rule, $recurring_fine_rule, $max_fine_rule, $hard_due_date);
1224 $$results{$_} = $$policy{$_} for keys %$policy;
1230 # ---------------------------------------------------------------------
1231 # Loads the circ policy info for duration, recurring fine, and max
1232 # fine based on the current copy
1233 # ---------------------------------------------------------------------
1234 sub get_circ_policy {
1235 my($self, $duration_rule, $recurring_fine_rule, $max_fine_rule, $hard_due_date) = @_;
1238 duration_rule => $duration_rule->name,
1239 recurring_fine_rule => $recurring_fine_rule->name,
1240 max_fine_rule => $max_fine_rule->name,
1241 max_fine => $self->get_max_fine_amount($max_fine_rule),
1242 fine_interval => $recurring_fine_rule->recurrence_interval,
1243 renewal_remaining => $duration_rule->max_renewals,
1244 grace_period => $recurring_fine_rule->grace_period
1247 if($hard_due_date) {
1248 $policy->{duration_date_ceiling} = $hard_due_date->ceiling_date;
1249 $policy->{duration_date_ceiling_force} = $hard_due_date->forceto;
1252 $policy->{duration_date_ceiling} = undef;
1253 $policy->{duration_date_ceiling_force} = undef;
1256 $policy->{duration} = $duration_rule->shrt
1257 if $self->copy->loan_duration == OILS_CIRC_DURATION_SHORT;
1258 $policy->{duration} = $duration_rule->normal
1259 if $self->copy->loan_duration == OILS_CIRC_DURATION_NORMAL;
1260 $policy->{duration} = $duration_rule->extended
1261 if $self->copy->loan_duration == OILS_CIRC_DURATION_EXTENDED;
1263 $policy->{recurring_fine} = $recurring_fine_rule->low
1264 if $self->copy->fine_level == OILS_REC_FINE_LEVEL_LOW;
1265 $policy->{recurring_fine} = $recurring_fine_rule->normal
1266 if $self->copy->fine_level == OILS_REC_FINE_LEVEL_NORMAL;
1267 $policy->{recurring_fine} = $recurring_fine_rule->high
1268 if $self->copy->fine_level == OILS_REC_FINE_LEVEL_HIGH;
1273 sub get_max_fine_amount {
1275 my $max_fine_rule = shift;
1276 my $max_amount = $max_fine_rule->amount;
1278 # if is_percent is true then the max->amount is
1279 # use as a percentage of the copy price
1280 if ($U->is_true($max_fine_rule->is_percent)) {
1281 my $price = $U->get_copy_price($self->editor, $self->copy, $self->volume);
1282 $max_amount = $price * $max_fine_rule->amount / 100;
1284 $U->ou_ancestor_setting_value(
1286 'circ.max_fine.cap_at_price',
1290 my $price = $U->get_copy_price($self->editor, $self->copy, $self->volume);
1291 $max_amount = ( $price && $max_amount > $price ) ? $price : $max_amount;
1299 sub run_copy_permit_scripts {
1301 my $copy = $self->copy || return;
1302 my $runner = $self->script_runner;
1306 if(!$self->legacy_script_support) {
1307 my $results = $self->run_indb_circ_test;
1308 push @allevents, $self->matrix_test_result_events
1309 unless $self->circ_test_success;
1312 # ---------------------------------------------------------------------
1313 # Capture all of the copy permit events
1314 # ---------------------------------------------------------------------
1315 $runner->load($self->circ_permit_copy);
1316 my $result = $runner->run or
1317 throw OpenSRF::EX::ERROR ("Circ Permit Copy Script Died: $@");
1318 my $copy_events = $result->{events};
1320 # ---------------------------------------------------------------------
1321 # Now collect all of the events together
1322 # ---------------------------------------------------------------------
1323 push( @allevents, OpenILS::Event->new($_)) for @$copy_events;
1326 # See if this copy has an alert message
1327 my $ae = $self->check_copy_alert();
1328 push( @allevents, $ae ) if $ae;
1330 # uniquify the events
1331 my %hash = map { ($_->{ilsevent} => $_) } @allevents;
1332 @allevents = values %hash;
1334 $logger->info("circulator: permit_copy script returned events: @allevents") if @allevents;
1336 $self->push_events(@allevents);
1340 sub check_copy_alert {
1342 return undef if $self->is_renewal;
1343 return OpenILS::Event->new(
1344 'COPY_ALERT_MESSAGE', payload => $self->copy->alert_message)
1345 if $self->copy and $self->copy->alert_message;
1351 # --------------------------------------------------------------------------
1352 # If the call is overriding and has permissions to override every collected
1353 # event, the are cleared. Any event that the caller does not have
1354 # permission to override, will be left in the event list and bail_out will
1356 # XXX We need code in here to cancel any holds/transits on copies
1357 # that are being force-checked out
1358 # --------------------------------------------------------------------------
1359 sub override_events {
1361 my @events = @{$self->events};
1362 return unless @events;
1364 if(!$self->override) {
1365 return $self->bail_out(1)
1366 if( @events > 1 or $events[0]->{textcode} ne 'SUCCESS' );
1371 for my $e (@events) {
1372 my $tc = $e->{textcode};
1373 next if $tc eq 'SUCCESS';
1374 my $ov = "$tc.override";
1375 $logger->info("circulator: attempting to override event: $ov");
1377 return $self->bail_on_events($self->editor->event)
1378 unless( $self->editor->allowed($ov) );
1383 # --------------------------------------------------------------------------
1384 # If there is an open claimsreturn circ on the requested copy, close the
1385 # circ if overriding, otherwise bail out
1386 # --------------------------------------------------------------------------
1387 sub handle_claims_returned {
1389 my $copy = $self->copy;
1391 my $CR = $self->editor->search_action_circulation(
1393 target_copy => $copy->id,
1394 stop_fines => OILS_STOP_FINES_CLAIMSRETURNED,
1395 checkin_time => undef,
1399 return unless ($CR = $CR->[0]);
1403 # - If the caller has set the override flag, we will check the item in
1404 if($self->override) {
1406 $CR->checkin_time('now');
1407 $CR->checkin_scan_time('now');
1408 $CR->checkin_lib($self->circ_lib);
1409 $CR->checkin_workstation($self->editor->requestor->wsid);
1410 $CR->checkin_staff($self->editor->requestor->id);
1412 $evt = $self->editor->event
1413 unless $self->editor->update_action_circulation($CR);
1416 $evt = OpenILS::Event->new('CIRC_CLAIMS_RETURNED');
1419 $self->bail_on_events($evt) if $evt;
1424 # --------------------------------------------------------------------------
1425 # This performs the checkout
1426 # --------------------------------------------------------------------------
1430 $self->log_me("do_checkout()");
1432 # make sure perms are good if this isn't a renewal
1433 unless( $self->is_renewal ) {
1434 return $self->bail_on_events($self->editor->event)
1435 unless( $self->editor->allowed('COPY_CHECKOUT') );
1438 # verify the permit key
1439 unless( $self->check_permit_key ) {
1440 if( $self->permit_override ) {
1441 return $self->bail_on_events($self->editor->event)
1442 unless $self->editor->allowed('CIRC_PERMIT_OVERRIDE');
1444 return $self->bail_on_events(OpenILS::Event->new('CIRC_PERMIT_BAD_KEY'))
1448 # if this is a non-cataloged circ, build the circ and finish
1449 if( $self->is_noncat ) {
1450 $self->checkout_noncat;
1452 OpenILS::Event->new('SUCCESS',
1453 payload => { noncat_circ => $self->circ }));
1457 if( $self->is_precat ) {
1458 $self->make_precat_copy;
1459 return if $self->bail_out;
1461 } elsif( $self->copy->call_number == OILS_PRECAT_CALL_NUMBER ) {
1462 return $self->bail_on_events(OpenILS::Event->new('ITEM_NOT_CATALOGED'));
1465 $self->do_copy_checks;
1466 return if $self->bail_out;
1468 $self->run_checkout_scripts();
1469 return if $self->bail_out;
1471 $self->build_checkout_circ_object();
1472 return if $self->bail_out;
1474 my $modify_to_start = $self->booking_adjusted_due_date();
1475 return if $self->bail_out;
1477 $self->apply_modified_due_date($modify_to_start);
1478 return if $self->bail_out;
1480 return $self->bail_on_events($self->editor->event)
1481 unless $self->editor->create_action_circulation($self->circ);
1483 # refresh the circ to force local time zone for now
1484 $self->circ($self->editor->retrieve_action_circulation($self->circ->id));
1486 $self->copy->status(OILS_COPY_STATUS_CHECKED_OUT);
1488 return if $self->bail_out;
1490 $self->apply_deposit_fee();
1491 return if $self->bail_out;
1493 $self->handle_checkout_holds();
1494 return if $self->bail_out;
1496 # ------------------------------------------------------------------------------
1497 # Update the patron penalty info in the DB. Run it for permit-overrides
1498 # since the penalties are not updated during the permit phase
1499 # ------------------------------------------------------------------------------
1500 OpenILS::Utils::Penalty->calculate_penalties($self->editor, $self->patron->id, $self->circ_lib);
1502 my $record = $U->record_to_mvr($self->title) unless $self->is_precat;
1505 if($self->is_renewal) {
1506 # flesh the billing summary for the checked-in circ
1507 $pcirc = $self->editor->retrieve_action_circulation([
1509 {flesh => 2, flesh_fields => {circ => ['billable_transaction'], mbt => ['summary']}}
1514 OpenILS::Event->new('SUCCESS',
1516 copy => $U->unflesh_copy($self->copy),
1517 volume => $self->volume,
1518 circ => $self->circ,
1520 holds_fulfilled => $self->fulfilled_holds,
1521 deposit_billing => $self->deposit_billing,
1522 rental_billing => $self->rental_billing,
1523 parent_circ => $pcirc,
1524 patron => ($self->return_patron) ? $self->patron : undef,
1525 patron_money => $self->editor->retrieve_money_user_summary($self->patron->id)
1531 sub apply_deposit_fee {
1533 my $copy = $self->copy;
1535 ($self->is_deposit and not $self->is_deposit_exempt) or
1536 ($self->is_rental and not $self->is_rental_exempt);
1538 return if $self->is_deposit and $self->skip_deposit_fee;
1539 return if $self->is_rental and $self->skip_rental_fee;
1541 my $bill = Fieldmapper::money::billing->new;
1542 my $amount = $copy->deposit_amount;
1546 if($self->is_deposit) {
1547 $billing_type = OILS_BILLING_TYPE_DEPOSIT;
1549 $self->deposit_billing($bill);
1551 $billing_type = OILS_BILLING_TYPE_RENTAL;
1553 $self->rental_billing($bill);
1556 $bill->xact($self->circ->id);
1557 $bill->amount($amount);
1558 $bill->note(OILS_BILLING_NOTE_SYSTEM);
1559 $bill->billing_type($billing_type);
1560 $bill->btype($btype);
1561 $self->editor->create_money_billing($bill) or $self->bail_on_events($self->editor->event);
1563 $logger->info("circulator: charged $amount on checkout with billing type $billing_type");
1568 my $copy = $self->copy;
1570 my $stat = $copy->status if ref $copy->status;
1571 my $loc = $copy->location if ref $copy->location;
1572 my $circ_lib = $copy->circ_lib if ref $copy->circ_lib;
1574 $copy->status($stat->id) if $stat;
1575 $copy->location($loc->id) if $loc;
1576 $copy->circ_lib($circ_lib->id) if $circ_lib;
1577 $copy->editor($self->editor->requestor->id);
1578 $copy->edit_date('now');
1579 $copy->age_protect($copy->age_protect->id) if ref $copy->age_protect;
1581 return $self->bail_on_events($self->editor->event)
1582 unless $self->editor->update_asset_copy($self->copy);
1584 $copy->status($U->copy_status($copy->status));
1585 $copy->location($loc) if $loc;
1586 $copy->circ_lib($circ_lib) if $circ_lib;
1589 sub update_reservation {
1591 my $reservation = $self->reservation;
1593 my $usr = $reservation->usr;
1594 my $target_rt = $reservation->target_resource_type;
1595 my $target_r = $reservation->target_resource;
1596 my $current_r = $reservation->current_resource;
1598 $reservation->usr($usr->id) if ref $usr;
1599 $reservation->target_resource_type($target_rt->id) if ref $target_rt;
1600 $reservation->target_resource($target_r->id) if ref $target_r;
1601 $reservation->current_resource($current_r->id) if ref $current_r;
1603 return $self->bail_on_events($self->editor->event)
1604 unless $self->editor->update_booking_reservation($self->reservation);
1607 ($reservation, $evt) = $U->fetch_booking_reservation($reservation->id);
1608 $self->reservation($reservation);
1612 sub bail_on_events {
1613 my( $self, @evts ) = @_;
1614 $self->push_events(@evts);
1619 # ------------------------------------------------------------------------------
1620 # When an item is checked out, see if we can fulfill a hold for this patron
1621 # ------------------------------------------------------------------------------
1622 sub handle_checkout_holds {
1624 my $copy = $self->copy;
1625 my $patron = $self->patron;
1627 my $e = $self->editor;
1628 $self->fulfilled_holds([]);
1630 # pre/non-cats can't fulfill a hold
1631 return if $self->is_precat or $self->is_noncat;
1633 my $hold = $e->search_action_hold_request({
1634 current_copy => $copy->id ,
1635 cancel_time => undef,
1636 fulfillment_time => undef,
1638 {expire_time => undef},
1639 {expire_time => {'>' => 'now'}}
1643 if($hold and $hold->usr != $patron->id) {
1644 # reset the hold since the copy is now checked out
1646 $logger->info("circulator: un-targeting hold ".$hold->id.
1647 " because copy ".$copy->id." is getting checked out");
1649 $hold->clear_prev_check_time;
1650 $hold->clear_current_copy;
1651 $hold->clear_capture_time;
1653 return $self->bail_on_event($e->event)
1654 unless $e->update_action_hold_request($hold);
1660 $hold = $self->find_related_user_hold($copy, $patron) or return;
1661 $logger->info("circulator: found related hold to fulfill in checkout");
1664 $logger->debug("circulator: checkout fulfilling hold " . $hold->id);
1666 # if the hold was never officially captured, capture it.
1667 $hold->current_copy($copy->id);
1668 $hold->capture_time('now') unless $hold->capture_time;
1669 $hold->fulfillment_time('now');
1670 $hold->fulfillment_staff($e->requestor->id);
1671 $hold->fulfillment_lib($self->circ_lib);
1673 return $self->bail_on_events($e->event)
1674 unless $e->update_action_hold_request($hold);
1676 $holdcode->delete_hold_copy_maps($e, $hold->id);
1677 return $self->fulfilled_holds([$hold->id]);
1681 # ------------------------------------------------------------------------------
1682 # If the circ.checkout_fill_related_hold setting is turned on and no hold for
1683 # the patron directly targets the checked out item, see if there is another hold
1684 # for the patron that could be fulfilled by the checked out item. Fulfill the
1685 # oldest hold and only fulfill 1 of them.
1687 # For "another hold":
1689 # First, check for one that the copy matches via hold_copy_map, ensuring that
1690 # *any* hold type that this copy could fill may end up filled.
1692 # Then, if circ.checkout_fill_related_hold_exact_match_only is not enabled, look
1693 # for a Title (T) or Volume (V) hold that matches the item. This allows items
1694 # that are non-requestable to count as capturing those hold types.
1695 # ------------------------------------------------------------------------------
1696 sub find_related_user_hold {
1697 my($self, $copy, $patron) = @_;
1698 my $e = $self->editor;
1700 return undef if $self->volume->id == OILS_PRECAT_CALL_NUMBER;
1702 return undef unless $U->ou_ancestor_setting_value(
1703 $self->circ_lib, 'circ.checkout_fills_related_hold', $e);
1705 # find the oldest unfulfilled hold that has not yet hit the holds shelf.
1707 select => {ahr => ['id']},
1719 fulfillment_time => undef,
1720 cancel_time => undef,
1722 {expire_time => undef},
1723 {expire_time => {'>' => 'now'}}
1727 target_copy => $self->copy->id
1730 order_by => {ahr => {request_time => {direction => 'asc'}}},
1734 my $hold_info = $e->json_query($args)->[0];
1735 return $e->retrieve_action_hold_request($hold_info->{id}) if $hold_info;
1736 return undef if $U->ou_ancestor_setting_value(
1737 $self->circ_lib, 'circ.checkout_fills_related_hold_exact_match_only', $e);
1739 # find the oldest unfulfilled hold that has not yet hit the holds shelf.
1741 select => {ahr => ['id']},
1746 fkey => 'current_copy',
1747 type => 'left' # there may be no current_copy
1754 fulfillment_time => undef,
1755 cancel_time => undef,
1757 {expire_time => undef},
1758 {expire_time => {'>' => 'now'}}
1765 target => $self->volume->id
1771 target => $self->title->id
1777 {id => undef}, # left-join copy may be nonexistent
1778 {status => {'!=' => OILS_COPY_STATUS_ON_HOLDS_SHELF}},
1782 order_by => {ahr => {request_time => {direction => 'asc'}}},
1786 $hold_info = $e->json_query($args)->[0];
1787 return $e->retrieve_action_hold_request($hold_info->{id}) if $hold_info;
1792 sub run_checkout_scripts {
1797 my $runner = $self->script_runner;
1806 my $hard_due_date_name;
1808 if(!$self->legacy_script_support) {
1809 $self->run_indb_circ_test();
1810 $duration = $self->circ_matrix_matchpoint->duration_rule;
1811 $recurring = $self->circ_matrix_matchpoint->recurring_fine_rule;
1812 $max_fine = $self->circ_matrix_matchpoint->max_fine_rule;
1813 $hard_due_date = $self->circ_matrix_matchpoint->hard_due_date;
1817 $runner->load($self->circ_duration);
1819 my $result = $runner->run or
1820 throw OpenSRF::EX::ERROR ("Circ Duration Script Died: $@");
1822 $duration_name = $result->{durationRule};
1823 $recurring_name = $result->{recurringFinesRule};
1824 $max_fine_name = $result->{maxFine};
1825 $hard_due_date_name = $result->{hardDueDate};
1828 $duration_name = $duration->name if $duration;
1829 if( $duration_name ne OILS_UNLIMITED_CIRC_DURATION ) {
1832 ($duration, $evt) = $U->fetch_circ_duration_by_name($duration_name);
1833 return $self->bail_on_events($evt) if ($evt && !$nobail);
1835 ($recurring, $evt) = $U->fetch_recurring_fine_by_name($recurring_name);
1836 return $self->bail_on_events($evt) if ($evt && !$nobail);
1838 ($max_fine, $evt) = $U->fetch_max_fine_by_name($max_fine_name);
1839 return $self->bail_on_events($evt) if ($evt && !$nobail);
1841 if($hard_due_date_name) {
1842 ($hard_due_date, $evt) = $U->fetch_hard_due_date_by_name($hard_due_date_name);
1843 return $self->bail_on_events($evt) if ($evt && !$nobail);
1849 # The item circulates with an unlimited duration
1853 $hard_due_date = undef;
1856 $self->duration_rule($duration);
1857 $self->recurring_fines_rule($recurring);
1858 $self->max_fine_rule($max_fine);
1859 $self->hard_due_date($hard_due_date);
1863 sub build_checkout_circ_object {
1866 my $circ = Fieldmapper::action::circulation->new;
1867 my $duration = $self->duration_rule;
1868 my $max = $self->max_fine_rule;
1869 my $recurring = $self->recurring_fines_rule;
1870 my $hard_due_date = $self->hard_due_date;
1871 my $copy = $self->copy;
1872 my $patron = $self->patron;
1873 my $duration_date_ceiling;
1874 my $duration_date_ceiling_force;
1878 my $policy = $self->get_circ_policy($duration, $recurring, $max, $hard_due_date);
1879 $duration_date_ceiling = $policy->{duration_date_ceiling};
1880 $duration_date_ceiling_force = $policy->{duration_date_ceiling_force};
1882 my $dname = $duration->name;
1883 my $mname = $max->name;
1884 my $rname = $recurring->name;
1886 if($hard_due_date) {
1887 $hdname = $hard_due_date->name;
1890 $logger->debug("circulator: building circulation ".
1891 "with duration=$dname, maxfine=$mname, recurring=$rname, hard due date=$hdname");
1893 $circ->duration($policy->{duration});
1894 $circ->recurring_fine($policy->{recurring_fine});
1895 $circ->duration_rule($duration->name);
1896 $circ->recurring_fine_rule($recurring->name);
1897 $circ->max_fine_rule($max->name);
1898 $circ->max_fine($policy->{max_fine});
1899 $circ->fine_interval($recurring->recurrence_interval);
1900 $circ->renewal_remaining($duration->max_renewals);
1901 $circ->grace_period($policy->{grace_period});
1905 $logger->info("circulator: copy found with an unlimited circ duration");
1906 $circ->duration_rule(OILS_UNLIMITED_CIRC_DURATION);
1907 $circ->recurring_fine_rule(OILS_UNLIMITED_CIRC_DURATION);
1908 $circ->max_fine_rule(OILS_UNLIMITED_CIRC_DURATION);
1909 $circ->renewal_remaining(0);
1910 $circ->grace_period(0);
1913 $circ->target_copy( $copy->id );
1914 $circ->usr( $patron->id );
1915 $circ->circ_lib( $self->circ_lib );
1916 $circ->workstation($self->editor->requestor->wsid)
1917 if defined $self->editor->requestor->wsid;
1919 # renewals maintain a link to the parent circulation
1920 $circ->parent_circ($self->parent_circ);
1922 if( $self->is_renewal ) {
1923 $circ->opac_renewal('t') if $self->opac_renewal;
1924 $circ->phone_renewal('t') if $self->phone_renewal;
1925 $circ->desk_renewal('t') if $self->desk_renewal;
1926 $circ->renewal_remaining($self->renewal_remaining);
1927 $circ->circ_staff($self->editor->requestor->id);
1931 # if the user provided an overiding checkout time,
1932 # (e.g. the checkout really happened several hours ago), then
1933 # we apply that here. Does this need a perm??
1934 $circ->xact_start(cleanse_ISO8601($self->checkout_time))
1935 if $self->checkout_time;
1937 # if a patron is renewing, 'requestor' will be the patron
1938 $circ->circ_staff($self->editor->requestor->id);
1939 $circ->due_date( $self->create_due_date($circ->duration, $duration_date_ceiling, $duration_date_ceiling_force) ) if $circ->duration;
1944 sub do_reservation_pickup {
1947 $self->log_me("do_reservation_pickup()");
1949 $self->reservation->pickup_time('now');
1952 $self->reservation->current_resource &&
1953 $U->is_true($self->reservation->target_resource_type->catalog_item)
1955 # We used to try to set $self->copy and $self->patron here,
1956 # but that should already be done.
1958 $self->run_checkout_scripts(1);
1960 my $duration = $self->duration_rule;
1961 my $max = $self->max_fine_rule;
1962 my $recurring = $self->recurring_fines_rule;
1964 if ($duration && $max && $recurring) {
1965 my $policy = $self->get_circ_policy($duration, $recurring, $max);
1967 my $dname = $duration->name;
1968 my $mname = $max->name;
1969 my $rname = $recurring->name;
1971 $logger->debug("circulator: updating reservation ".
1972 "with duration=$dname, maxfine=$mname, recurring=$rname");
1974 $self->reservation->fine_amount($policy->{recurring_fine});
1975 $self->reservation->max_fine($policy->{max_fine});
1976 $self->reservation->fine_interval($recurring->recurrence_interval);
1979 $self->copy->status(OILS_COPY_STATUS_CHECKED_OUT);
1980 $self->update_copy();
1983 $self->reservation->fine_amount(
1984 $self->reservation->target_resource_type->fine_amount
1986 $self->reservation->max_fine(
1987 $self->reservation->target_resource_type->max_fine
1989 $self->reservation->fine_interval(
1990 $self->reservation->target_resource_type->fine_interval
1994 $self->update_reservation();
1997 sub do_reservation_return {
1999 my $request = shift;
2001 $self->log_me("do_reservation_return()");
2003 if (not ref $self->reservation) {
2004 my ($reservation, $evt) =
2005 $U->fetch_booking_reservation($self->reservation);
2006 return $self->bail_on_events($evt) if $evt;
2007 $self->reservation($reservation);
2010 $self->generate_fines(1);
2011 $self->reservation->return_time('now');
2012 $self->update_reservation();
2013 $self->reshelve_copy if $self->copy;
2015 if ( $self->reservation->current_resource && $self->reservation->current_resource->catalog_item ) {
2016 $self->copy( $self->reservation->current_resource->catalog_item );
2020 sub booking_adjusted_due_date {
2022 my $circ = $self->circ;
2023 my $copy = $self->copy;
2025 return undef unless $self->use_booking;
2029 if( $self->due_date ) {
2031 return $self->bail_on_events($self->editor->event)
2032 unless $self->editor->allowed('CIRC_OVERRIDE_DUE_DATE', $self->circ_lib);
2034 $circ->due_date(cleanse_ISO8601($self->due_date));
2038 return unless $copy and $circ->due_date;
2041 my $booking_items = $self->editor->search_booking_resource( { barcode => $copy->barcode } );
2042 if (@$booking_items) {
2043 my $booking_item = $booking_items->[0];
2044 my $resource_type = $self->editor->retrieve_booking_resource_type( $booking_item->type );
2046 my $stop_circ_setting = $U->ou_ancestor_setting_value( $self->circ_lib, 'circ.booking_reservation.stop_circ', $self->editor );
2047 my $shorten_circ_setting = $resource_type->elbow_room ||
2048 $U->ou_ancestor_setting_value( $self->circ_lib, 'circ.booking_reservation.default_elbow_room', $self->editor ) ||
2051 my $booking_ses = OpenSRF::AppSession->create( 'open-ils.booking' );
2052 my $bookings = $booking_ses->request(
2053 'open-ils.booking.reservations.filtered_id_list', $self->editor->authtoken,
2054 { resource => $booking_item->id, search_start => 'now', search_end => $circ->due_date, fields => { cancel_time => undef }}
2056 $booking_ses->disconnect;
2058 my $dt_parser = DateTime::Format::ISO8601->new;
2059 my $due_date = $dt_parser->parse_datetime( cleanse_ISO8601($circ->due_date) );
2061 for my $bid (@$bookings) {
2063 my $booking = $self->editor->retrieve_booking_reservation( $bid );
2065 my $booking_start = $dt_parser->parse_datetime( cleanse_ISO8601($booking->start_time) );
2066 my $booking_end = $dt_parser->parse_datetime( cleanse_ISO8601($booking->end_time) );
2068 return $self->bail_on_events( OpenILS::Event->new('COPY_RESERVED') )
2069 if ($booking_start < DateTime->now);
2072 if ($U->is_true($stop_circ_setting)) {
2073 $self->bail_on_events( OpenILS::Event->new('COPY_RESERVED') );
2075 $due_date = $booking_start->subtract( seconds => interval_to_seconds($shorten_circ_setting) );
2076 $self->bail_on_events( OpenILS::Event->new('COPY_RESERVED') ) if ($due_date < DateTime->now);
2079 # We set the circ duration here only to affect the logic that will
2080 # later (in a DB trigger) mangle the time part of the due date to
2081 # 11:59pm. Having any circ duration that is not a whole number of
2082 # days is enough to prevent the "correction."
2083 my $new_circ_duration = $due_date->epoch - time;
2084 $new_circ_duration++ if $new_circ_duration % 86400 == 0;
2085 $circ->duration("$new_circ_duration seconds");
2087 $circ->due_date(cleanse_ISO8601($due_date->strftime('%FT%T%z')));
2091 return $self->bail_on_events($self->editor->event)
2092 unless $self->editor->allowed('CIRC_OVERRIDE_DUE_DATE', $self->circ_lib);
2098 sub apply_modified_due_date {
2100 my $shift_earlier = shift;
2101 my $circ = $self->circ;
2102 my $copy = $self->copy;
2104 if( $self->due_date ) {
2106 return $self->bail_on_events($self->editor->event)
2107 unless $self->editor->allowed('CIRC_OVERRIDE_DUE_DATE', $self->circ_lib);
2109 $circ->due_date(cleanse_ISO8601($self->due_date));
2113 # if the due_date lands on a day when the location is closed
2114 return unless $copy and $circ->due_date;
2116 #my $org = (ref $copy->circ_lib) ? $copy->circ_lib->id : $copy->circ_lib;
2118 # due-date overlap should be determined by the location the item
2119 # is checked out from, not the owning or circ lib of the item
2120 my $org = $self->circ_lib;
2122 $logger->info("circulator: circ searching for closed date overlap on lib $org".
2123 " with an item due date of ".$circ->due_date );
2125 my $dateinfo = $U->storagereq(
2126 'open-ils.storage.actor.org_unit.closed_date.overlap',
2127 $org, $circ->due_date );
2130 $logger->info("circulator: $dateinfo : circ due data / close date overlap found : due_date=".
2131 $circ->due_date." start=". $dateinfo->{start}.", end=".$dateinfo->{end});
2133 # XXX make the behavior more dynamic
2134 # for now, we just push the due date to after the close date
2135 if ($shift_earlier) {
2136 $circ->due_date($dateinfo->{start});
2138 $circ->due_date($dateinfo->{end});
2146 sub create_due_date {
2147 my( $self, $duration, $date_ceiling, $force_date ) = @_;
2149 # if there is a raw time component (e.g. from postgres),
2150 # turn it into an interval that interval_to_seconds can parse
2151 $duration =~ s/(\d{2}):(\d{2}):(\d{2})/$1 h $2 m $3 s/o;
2153 # for now, use the server timezone. TODO: use workstation org timezone
2154 my $due_date = DateTime->now(time_zone => 'local');
2156 # add the circ duration
2157 $due_date->add(seconds => OpenSRF::Utils->interval_to_seconds($duration));
2160 my $cdate = DateTime::Format::ISO8601->new->parse_datetime(cleanse_ISO8601($date_ceiling));
2161 if ($cdate > DateTime->now and ($cdate < $due_date or $U->is_true( $force_date ))) {
2162 $logger->info("circulator: overriding due date with date ceiling: $date_ceiling");
2167 # return ISO8601 time with timezone
2168 return $due_date->strftime('%FT%T%z');
2173 sub make_precat_copy {
2175 my $copy = $self->copy;
2178 $logger->debug("circulator: Pre-cat copy already exists in checkout: ID=" . $copy->id);
2180 $copy->editor($self->editor->requestor->id);
2181 $copy->edit_date('now');
2182 $copy->dummy_title($self->dummy_title || $copy->dummy_title || '');
2183 $copy->dummy_isbn($self->dummy_isbn || $copy->dummy_isbn || '');
2184 $copy->dummy_author($self->dummy_author || $copy->dummy_author || '');
2185 $copy->circ_modifier($self->circ_modifier || $copy->circ_modifier);
2186 $self->update_copy();
2190 $logger->info("circulator: Creating a new precataloged ".
2191 "copy in checkout with barcode " . $self->copy_barcode);
2193 $copy = Fieldmapper::asset::copy->new;
2194 $copy->circ_lib($self->circ_lib);
2195 $copy->creator($self->editor->requestor->id);
2196 $copy->editor($self->editor->requestor->id);
2197 $copy->barcode($self->copy_barcode);
2198 $copy->call_number(OILS_PRECAT_CALL_NUMBER);
2199 $copy->loan_duration(OILS_PRECAT_COPY_LOAN_DURATION);
2200 $copy->fine_level(OILS_PRECAT_COPY_FINE_LEVEL);
2202 $copy->dummy_title($self->dummy_title || "");
2203 $copy->dummy_author($self->dummy_author || "");
2204 $copy->dummy_isbn($self->dummy_isbn || "");
2205 $copy->circ_modifier($self->circ_modifier);
2208 # See if we need to override the circ_lib for the copy with a configured circ_lib
2209 # Setting is shortname of the org unit
2210 my $precat_circ_lib = $U->ou_ancestor_setting_value(
2211 $self->circ_lib, 'circ.pre_cat_copy_circ_lib', $self->editor);
2213 if($precat_circ_lib) {
2214 my $org = $self->editor->search_actor_org_unit({shortname => $precat_circ_lib})->[0];
2217 $self->bail_on_events($self->editor->event);
2221 $copy->circ_lib($org->id);
2225 unless( $self->copy($self->editor->create_asset_copy($copy)) ) {
2227 $self->push_events($self->editor->event);
2231 # this is a little bit of a hack, but we need to
2232 # get the copy into the script runner
2233 $self->script_runner->insert("environment.copy", $copy, 1) if $self->script_runner;
2237 sub checkout_noncat {
2243 my $lib = $self->noncat_circ_lib || $self->circ_lib;
2244 my $count = $self->noncat_count || 1;
2245 my $cotime = cleanse_ISO8601($self->checkout_time) || "";
2247 $logger->info("circulator: circ creating $count noncat circs with checkout time $cotime");
2251 ( $circ, $evt ) = OpenILS::Application::Circ::NonCat::create_non_cat_circ(
2252 $self->editor->requestor->id,
2260 $self->push_events($evt);
2268 # If a copy goes into transit and is then checked in before the transit checkin
2269 # interval has expired, push an event onto the overridable events list.
2270 sub check_transit_checkin_interval {
2273 # only concerned with in-transit items
2274 return unless $U->copy_status($self->copy->status)->id == OILS_COPY_STATUS_IN_TRANSIT;
2276 # no interval, no problem
2277 my $interval = $U->ou_ancestor_setting_value($self->circ_lib, 'circ.transit.min_checkin_interval');
2278 return unless $interval;
2280 # capture the transit so we don't have to fetch it again later during checkin
2282 $self->editor->search_action_transit_copy(
2283 {target_copy => $self->copy->id, dest_recv_time => undef}
2287 my $seconds = OpenSRF::Utils->interval_to_seconds($interval);
2288 my $t_start = DateTime::Format::ISO8601->new->parse_datetime(cleanse_ISO8601($self->transit->source_send_time));
2289 my $horizon = $t_start->add(seconds => $seconds);
2291 # See if we are still within the transit checkin forbidden range
2292 $self->push_events(OpenILS::Event->new('TRANSIT_CHECKIN_INTERVAL_BLOCK'))
2293 if $horizon > DateTime->now;
2299 $self->log_me("do_checkin()");
2301 return $self->bail_on_events(
2302 OpenILS::Event->new('ASSET_COPY_NOT_FOUND'))
2305 $self->check_transit_checkin_interval;
2307 # the renew code and mk_env should have already found our circulation object
2308 unless( $self->circ ) {
2310 my $circs = $self->editor->search_action_circulation(
2311 { target_copy => $self->copy->id, checkin_time => undef });
2313 $self->circ($$circs[0]);
2315 # for now, just warn if there are multiple open circs on a copy
2316 $logger->warn("circulator: we have ".scalar(@$circs).
2317 " open circs for copy " .$self->copy->id."!!") if @$circs > 1;
2320 # run the fine generator against this circ, if this circ is there
2321 $self->generate_fines_start if $self->circ;
2323 if( $self->checkin_check_holds_shelf() ) {
2324 $self->bail_on_events(OpenILS::Event->new('NO_CHANGE'));
2325 $self->hold($U->fetch_open_hold_by_copy($self->copy->id));
2326 $self->checkin_flesh_events;
2330 unless( $self->is_renewal ) {
2331 return $self->bail_on_events($self->editor->event)
2332 unless $self->editor->allowed('COPY_CHECKIN');
2335 $self->push_events($self->check_copy_alert());
2336 $self->push_events($self->check_checkin_copy_status());
2338 # if the circ is marked as 'claims returned', add the event to the list
2339 $self->push_events(OpenILS::Event->new('CIRC_CLAIMS_RETURNED'))
2340 if ($self->circ and $self->circ->stop_fines
2341 and $self->circ->stop_fines eq OILS_STOP_FINES_CLAIMSRETURNED);
2343 $self->check_circ_deposit();
2345 # handle the overridable events
2346 $self->override_events unless $self->is_renewal;
2347 return if $self->bail_out;
2349 if( $self->copy and !$self->transit ) {
2351 $self->editor->search_action_transit_copy(
2352 { target_copy => $self->copy->id, dest_recv_time => undef }
2358 $self->generate_fines_finish;
2359 $self->checkin_handle_circ;
2360 return if $self->bail_out;
2361 $self->checkin_changed(1);
2363 } elsif( $self->transit ) {
2364 my $hold_transit = $self->process_received_transit;
2365 $self->checkin_changed(1);
2367 if( $self->bail_out ) {
2368 $self->checkin_flesh_events;
2372 if( my $e = $self->check_checkin_copy_status() ) {
2373 # If the original copy status is special, alert the caller
2374 my $ev = $self->events;
2375 $self->events([$e]);
2376 $self->override_events;
2377 return if $self->bail_out;
2381 if( $hold_transit or
2382 $U->copy_status($self->copy->status)->id
2383 == OILS_COPY_STATUS_ON_HOLDS_SHELF ) {
2386 if( $hold_transit ) {
2387 $hold = $self->editor->retrieve_action_hold_request($hold_transit->hold);
2389 ($hold) = $U->fetch_open_hold_by_copy($self->copy->id);
2394 if( $hold and $hold->cancel_time ) { # this transited hold was cancelled mid-transit
2396 $logger->info("circulator: we received a transit on a cancelled hold " . $hold->id);
2397 $self->reshelve_copy(1);
2398 $self->cancelled_hold_transit(1);
2399 $self->notify_hold(0); # don't notify for cancelled holds
2400 return if $self->bail_out;
2404 # hold transited to correct location
2405 $self->checkin_flesh_events;
2410 } elsif( $U->copy_status($self->copy->status)->id == OILS_COPY_STATUS_IN_TRANSIT ) {
2412 $logger->warn("circulator: we have a copy ".$self->copy->barcode.
2413 " that is in-transit, but there is no transit.. repairing");
2414 $self->reshelve_copy(1);
2415 return if $self->bail_out;
2418 if( $self->is_renewal ) {
2419 $self->finish_fines_and_voiding;
2420 return if $self->bail_out;
2421 $self->push_events(OpenILS::Event->new('SUCCESS'));
2425 # ------------------------------------------------------------------------------
2426 # Circulations and transits are now closed where necessary. Now go on to see if
2427 # this copy can fulfill a hold or needs to be routed to a different location
2428 # ------------------------------------------------------------------------------
2430 my $needed_for_something = 0; # formerly "needed_for_hold"
2432 if(!$self->noop) { # /not/ a no-op checkin, capture for hold or put item into transit
2434 if (!$self->remote_hold) {
2435 if ($self->use_booking) {
2436 my $potential_hold = $self->hold_capture_is_possible;
2437 my $potential_reservation = $self->reservation_capture_is_possible;
2439 if ($potential_hold and $potential_reservation) {
2440 $logger->info("circulator: item could fulfill either hold or reservation");
2441 $self->push_events(new OpenILS::Event(
2442 "HOLD_RESERVATION_CONFLICT",
2443 "hold" => $potential_hold,
2444 "reservation" => $potential_reservation
2446 return if $self->bail_out;
2447 } elsif ($potential_hold) {
2448 $needed_for_something =
2449 $self->attempt_checkin_hold_capture;
2450 } elsif ($potential_reservation) {
2451 $needed_for_something =
2452 $self->attempt_checkin_reservation_capture;
2455 $needed_for_something = $self->attempt_checkin_hold_capture;
2458 return if $self->bail_out;
2460 unless($needed_for_something) {
2461 my $circ_lib = (ref $self->copy->circ_lib) ?
2462 $self->copy->circ_lib->id : $self->copy->circ_lib;
2464 if( $self->remote_hold ) {
2465 $circ_lib = $self->remote_hold->pickup_lib;
2466 $logger->warn("circulator: Copy ".$self->copy->barcode.
2467 " is on a remote hold's shelf, sending to $circ_lib");
2470 $logger->debug("circulator: circlib=$circ_lib, workstation=".$self->circ_lib);
2472 if( $circ_lib == $self->circ_lib) {
2473 # copy is where it needs to be, either for hold or reshelving
2475 $self->checkin_handle_precat();
2476 return if $self->bail_out;
2479 # copy needs to transit "home", or stick here if it's a floating copy
2481 if ($U->is_true( $self->copy->floating ) && !$self->remote_hold) { # copy is floating, stick here
2482 $self->checkin_changed(1);
2483 $self->copy->circ_lib( $self->circ_lib );
2486 my $bc = $self->copy->barcode;
2487 $logger->info("circulator: copy $bc at the wrong location, sending to $circ_lib");
2488 $self->checkin_build_copy_transit($circ_lib);
2489 return if $self->bail_out;
2490 $self->push_events(OpenILS::Event->new('ROUTE_ITEM', org => $circ_lib));
2494 } else { # no-op checkin
2495 if ($U->is_true( $self->copy->floating )) { # XXX floating items still stick where they are even with no-op checkin?
2496 $self->checkin_changed(1);
2497 $self->copy->circ_lib( $self->circ_lib );
2502 if($self->claims_never_checked_out and
2503 $U->ou_ancestor_setting_value($self->circ->circ_lib, 'circ.claim_never_checked_out.mark_missing')) {
2505 # the item was not supposed to be checked out to the user and should now be marked as missing
2506 $self->copy->status(OILS_COPY_STATUS_MISSING);
2510 $self->reshelve_copy unless $needed_for_something;
2513 return if $self->bail_out;
2515 unless($self->checkin_changed) {
2517 $self->push_events(OpenILS::Event->new('NO_CHANGE'));
2518 my $stat = $U->copy_status($self->copy->status)->id;
2520 $self->hold($U->fetch_open_hold_by_copy($self->copy->id))
2521 if( $stat == OILS_COPY_STATUS_ON_HOLDS_SHELF );
2522 $self->bail_out(1); # no need to commit anything
2526 $self->push_events(OpenILS::Event->new('SUCCESS'))
2527 unless @{$self->events};
2530 $self->finish_fines_and_voiding;
2532 OpenILS::Utils::Penalty->calculate_penalties(
2533 $self->editor, $self->patron->id, $self->circ_lib) if $self->patron;
2535 $self->checkin_flesh_events;
2539 sub finish_fines_and_voiding {
2541 return unless $self->circ;
2543 # gather any updates to the circ after fine generation, if there was a circ
2544 $self->generate_fines_finish;
2546 return unless $self->backdate or $self->void_overdues;
2548 # void overdues after fine generation to prevent concurrent DB access to overdue billings
2549 my $note = 'System: Amnesty Checkin' if $self->void_overdues;
2551 my $evt = OpenILS::Application::Circ::CircCommon->void_overdues(
2552 $self->editor, $self->circ, $self->backdate, $note);
2554 return $self->bail_on_events($evt) if $evt;
2556 # make sure the circ isn't closed if we just voided some fines
2557 $evt = OpenILS::Application::Circ::CircCommon->reopen_xact($self->editor, $self->circ->id);
2558 return $self->bail_on_events($evt) if $evt;
2564 # if a deposit was payed for this item, push the event
2565 sub check_circ_deposit {
2567 return unless $self->circ;
2568 my $deposit = $self->editor->search_money_billing(
2570 xact => $self->circ->id,
2572 }, {idlist => 1})->[0];
2574 $self->push_events(OpenILS::Event->new(
2575 'ITEM_DEPOSIT_PAID', payload => $deposit)) if $deposit;
2580 my $force = $self->force || shift;
2581 my $copy = $self->copy;
2583 my $stat = $U->copy_status($copy->status)->id;
2586 $stat != OILS_COPY_STATUS_ON_HOLDS_SHELF and
2587 $stat != OILS_COPY_STATUS_CATALOGING and
2588 $stat != OILS_COPY_STATUS_IN_TRANSIT and
2589 $stat != OILS_COPY_STATUS_RESHELVING )) {
2591 $copy->status( OILS_COPY_STATUS_RESHELVING );
2593 $self->checkin_changed(1);
2598 # Returns true if the item is at the current location
2599 # because it was transited there for a hold and the
2600 # hold has not been fulfilled
2601 sub checkin_check_holds_shelf {
2603 return 0 unless $self->copy;
2606 $U->copy_status($self->copy->status)->id ==
2607 OILS_COPY_STATUS_ON_HOLDS_SHELF;
2609 # find the hold that put us on the holds shelf
2610 my $holds = $self->editor->search_action_hold_request(
2612 current_copy => $self->copy->id,
2613 capture_time => { '!=' => undef },
2614 fulfillment_time => undef,
2615 cancel_time => undef,
2620 $logger->warn("circulator: copy is on-holds-shelf, but there is no hold - reshelving");
2621 $self->reshelve_copy(1);
2625 my $hold = $$holds[0];
2627 $logger->info("circulator: we found a captured, un-fulfilled hold [".
2628 $hold->id. "] for copy ".$self->copy->barcode);
2630 if( $hold->pickup_lib == $self->circ_lib ) {
2631 $logger->info("circulator: hold is for here .. we're done: ".$self->copy->barcode);
2635 $logger->info("circulator: hold is not for here..");
2636 $self->remote_hold($hold);
2641 sub checkin_handle_precat {
2643 my $copy = $self->copy;
2645 if( $self->is_precat and ($copy->status != OILS_COPY_STATUS_CATALOGING) ) {
2646 $copy->status(OILS_COPY_STATUS_CATALOGING);
2647 $self->update_copy();
2648 $self->checkin_changed(1);
2649 $self->push_events(OpenILS::Event->new('ITEM_NOT_CATALOGED'));
2654 sub checkin_build_copy_transit {
2657 my $copy = $self->copy;
2658 my $transit = Fieldmapper::action::transit_copy->new;
2660 #$dest ||= (ref($copy->circ_lib)) ? $copy->circ_lib->id : $copy->circ_lib;
2661 $logger->info("circulator: transiting copy to $dest");
2663 $transit->source($self->circ_lib);
2664 $transit->dest($dest);
2665 $transit->target_copy($copy->id);
2666 $transit->source_send_time('now');
2667 $transit->copy_status( $U->copy_status($copy->status)->id );
2669 $logger->debug("circulator: setting copy status on transit: ".$transit->copy_status);
2671 return $self->bail_on_events($self->editor->event)
2672 unless $self->editor->create_action_transit_copy($transit);
2674 $copy->status(OILS_COPY_STATUS_IN_TRANSIT);
2676 $self->checkin_changed(1);
2680 sub hold_capture_is_possible {
2682 my $copy = $self->copy;
2684 # we've been explicitly told not to capture any holds
2685 return 0 if $self->capture eq 'nocapture';
2687 # See if this copy can fulfill any holds
2688 my $hold = $holdcode->find_nearest_permitted_hold(
2689 $self->editor, $copy, $self->editor->requestor, 1 # check_only
2691 return undef if ref $hold eq "HASH" and
2692 $hold->{"textcode"} eq "ACTION_HOLD_REQUEST_NOT_FOUND";
2696 sub reservation_capture_is_possible {
2698 my $copy = $self->copy;
2700 # we've been explicitly told not to capture any holds
2701 return 0 if $self->capture eq 'nocapture';
2703 my $booking_ses = OpenSRF::AppSession->connect("open-ils.booking");
2704 my $resv = $booking_ses->request(
2705 "open-ils.booking.reservations.could_capture",
2706 $self->editor->authtoken, $copy->barcode
2708 $booking_ses->disconnect;
2709 if (ref($resv) eq "HASH" and exists $resv->{"textcode"}) {
2710 $self->push_events($resv);
2716 # returns true if the item was used (or may potentially be used
2717 # in subsequent calls) to capture a hold.
2718 sub attempt_checkin_hold_capture {
2720 my $copy = $self->copy;
2722 # we've been explicitly told not to capture any holds
2723 return 0 if $self->capture eq 'nocapture';
2725 # See if this copy can fulfill any holds
2726 my ($hold, undef, $retarget) = $holdcode->find_nearest_permitted_hold(
2727 $self->editor, $copy, $self->editor->requestor );
2730 $logger->debug("circulator: no potential permitted".
2731 "holds found for copy ".$copy->barcode);
2735 if($self->capture ne 'capture') {
2736 # see if this item is in a hold-capture-delay location
2737 my $location = $self->editor->retrieve_asset_copy_location($self->copy->location);
2738 if($U->is_true($location->hold_verify)) {
2739 $self->bail_on_events(
2740 OpenILS::Event->new('HOLD_CAPTURE_DELAYED', copy_location => $location));
2745 $self->retarget($retarget);
2747 $logger->info("circulator: found permitted hold ".$hold->id." for copy, capturing...");
2749 $hold->current_copy($copy->id);
2750 $hold->capture_time('now');
2751 $self->put_hold_on_shelf($hold)
2752 if $hold->pickup_lib == $self->circ_lib;
2754 # prevent DB errors caused by fetching
2755 # holds from storage, and updating through cstore
2756 $hold->clear_fulfillment_time;
2757 $hold->clear_fulfillment_staff;
2758 $hold->clear_fulfillment_lib;
2759 $hold->clear_expire_time;
2760 $hold->clear_cancel_time;
2761 $hold->clear_prev_check_time unless $hold->prev_check_time;
2763 $self->bail_on_events($self->editor->event)
2764 unless $self->editor->update_action_hold_request($hold);
2766 $self->checkin_changed(1);
2768 return 0 if $self->bail_out;
2770 if( $hold->pickup_lib == $self->circ_lib ) {
2772 # This hold was captured in the correct location
2773 $copy->status(OILS_COPY_STATUS_ON_HOLDS_SHELF);
2774 $self->push_events(OpenILS::Event->new('SUCCESS'));
2776 #$self->do_hold_notify($hold->id);
2777 $self->notify_hold($hold->id);
2781 # Hold needs to be picked up elsewhere. Build a hold
2782 # transit and route the item.
2783 $self->checkin_build_hold_transit();
2784 $copy->status(OILS_COPY_STATUS_IN_TRANSIT);
2785 return 0 if $self->bail_out;
2786 $self->push_events(OpenILS::Event->new('ROUTE_ITEM', org => $hold->pickup_lib));
2789 # make sure we save the copy status
2794 sub attempt_checkin_reservation_capture {
2796 my $copy = $self->copy;
2798 # we've been explicitly told not to capture any holds
2799 return 0 if $self->capture eq 'nocapture';
2801 my $booking_ses = OpenSRF::AppSession->connect("open-ils.booking");
2802 my $evt = $booking_ses->request(
2803 "open-ils.booking.resources.capture_for_reservation",
2804 $self->editor->authtoken,
2806 1 # don't update copy - we probably have it locked
2808 $booking_ses->disconnect;
2810 if (ref($evt) ne "HASH" or not exists $evt->{"textcode"}) {
2812 "open-ils.booking.resources.capture_for_reservation " .
2813 "didn't return an event!"
2817 $evt->{"textcode"} eq "RESERVATION_NOT_FOUND" and
2818 $evt->{"payload"}->{"fail_cause"} eq "not-transferable"
2820 # not-transferable is an error event we'll pass on the user
2821 $logger->warn("reservation capture attempted against non-transferable item");
2822 $self->push_events($evt);
2824 } elsif ($evt->{"textcode"} eq "SUCCESS") {
2825 # Re-retrieve copy as reservation capture may have changed
2826 # its status and whatnot.
2828 "circulator: booking capture win on copy " . $self->copy->id
2830 if (my $new_copy_status = $evt->{"payload"}->{"new_copy_status"}) {
2832 "circulator: changing copy " . $self->copy->id .
2833 "'s status from " . $self->copy->status . " to " .
2836 $self->copy->status($new_copy_status);
2839 $self->reservation($evt->{"payload"}->{"reservation"});
2841 if (exists $evt->{"payload"}->{"transit"}) {
2845 "org" => $evt->{"payload"}->{"transit"}->dest
2849 $self->checkin_changed(1);
2853 # other results are treated as "nothing to capture"
2857 sub do_hold_notify {
2858 my( $self, $holdid ) = @_;
2860 my $e = new_editor(xact => 1);
2861 my $hold = $e->retrieve_action_hold_request($holdid) or return $e->die_event;
2863 my $ses = OpenSRF::AppSession->create('open-ils.trigger');
2864 $ses->request('open-ils.trigger.event.autocreate', 'hold.available', $hold, $hold->pickup_lib);
2866 $logger->info("circulator: running delayed hold notify process");
2868 # my $notifier = OpenILS::Application::Circ::HoldNotify->new(
2869 # hold_id => $holdid, editor => new_editor(requestor=>$self->editor->requestor));
2871 my $notifier = OpenILS::Application::Circ::HoldNotify->new(
2872 hold_id => $holdid, requestor => $self->editor->requestor);
2874 $logger->debug("circulator: built hold notifier");
2876 if(!$notifier->event) {
2878 $logger->info("circulator: attempt at sending hold notification for hold $holdid");
2880 my $stat = $notifier->send_email_notify;
2881 if( $stat == '1' ) {
2882 $logger->info("circulator: hold notify succeeded for hold $holdid");
2886 $logger->debug("circulator: * hold notify cancelled or failed for hold $holdid");
2889 $logger->info("circulator: Not sending hold notification since the patron has no email address");
2893 sub retarget_holds {
2895 $logger->info("circulator: retargeting holds @{$self->retarget} after opportunistic capture");
2896 my $ses = OpenSRF::AppSession->create('open-ils.storage');
2897 $ses->request('open-ils.storage.action.hold_request.copy_targeter', undef, $self->retarget);
2898 # no reason to wait for the return value
2902 sub checkin_build_hold_transit {
2905 my $copy = $self->copy;
2906 my $hold = $self->hold;
2907 my $trans = Fieldmapper::action::hold_transit_copy->new;
2909 $logger->debug("circulator: building hold transit for ".$copy->barcode);
2911 $trans->hold($hold->id);
2912 $trans->source($self->circ_lib);
2913 $trans->dest($hold->pickup_lib);
2914 $trans->source_send_time("now");
2915 $trans->target_copy($copy->id);
2917 # when the copy gets to its destination, it will recover
2918 # this status - put it onto the holds shelf
2919 $trans->copy_status(OILS_COPY_STATUS_ON_HOLDS_SHELF);
2921 return $self->bail_on_events($self->editor->event)
2922 unless $self->editor->create_action_hold_transit_copy($trans);
2927 sub process_received_transit {
2929 my $copy = $self->copy;
2930 my $copyid = $self->copy->id;
2932 my $status_name = $U->copy_status($copy->status)->name;
2933 $logger->debug("circulator: attempting transit receive on ".
2934 "copy $copyid. Copy status is $status_name");
2936 my $transit = $self->transit;
2938 if( $transit->dest != $self->circ_lib ) {
2939 # - this item is in-transit to a different location
2941 my $tid = $transit->id;
2942 my $loc = $self->circ_lib;
2943 my $dest = $transit->dest;
2945 $logger->info("circulator: Fowarding transit on copy which is destined ".
2946 "for a different location. transit=$tid, copy=$copyid, current ".
2947 "location=$loc, destination location=$dest");
2949 my $evt = OpenILS::Event->new('ROUTE_ITEM', org => $dest, payload => {});
2951 # grab the associated hold object if available
2952 my $ht = $self->editor->retrieve_action_hold_transit_copy($tid);
2953 $self->hold($self->editor->retrieve_action_hold_request($ht->hold)) if $ht;
2955 return $self->bail_on_events($evt);
2958 # The transit is received, set the receive time
2959 $transit->dest_recv_time('now');
2960 $self->bail_on_events($self->editor->event)
2961 unless $self->editor->update_action_transit_copy($transit);
2963 my $hold_transit = $self->editor->retrieve_action_hold_transit_copy($transit->id);
2965 $logger->info("circulator: Recovering original copy status in transit: ".$transit->copy_status);
2966 $copy->status( $transit->copy_status );
2967 $self->update_copy();
2968 return if $self->bail_out;
2972 my $hold = $self->editor->retrieve_action_hold_request($hold_transit->hold);
2974 # hold has arrived at destination, set shelf time
2975 $self->put_hold_on_shelf($hold);
2976 $self->bail_on_events($self->editor->event)
2977 unless $self->editor->update_action_hold_request($hold);
2978 return if $self->bail_out;
2980 $self->notify_hold($hold_transit->hold);
2985 OpenILS::Event->new(
2988 payload => { transit => $transit, holdtransit => $hold_transit } ));
2990 return $hold_transit;
2994 # ------------------------------------------------------------------
2995 # Sets the shelf_time and shelf_expire_time for a newly shelved hold
2996 # ------------------------------------------------------------------
2997 sub put_hold_on_shelf {
2998 my($self, $hold) = @_;
3000 $hold->shelf_time('now');
3002 my $shelf_expire = $U->ou_ancestor_setting_value(
3003 $self->circ_lib, 'circ.holds.default_shelf_expire_interval', $self->editor);
3005 return undef unless $shelf_expire;
3007 my $seconds = OpenSRF::Utils->interval_to_seconds($shelf_expire);
3008 my $expire_time = DateTime->now->add(seconds => $seconds);
3010 # if the shelf expire time overlaps with a pickup lib's
3011 # closed date, push it out to the first open date
3012 my $dateinfo = $U->storagereq(
3013 'open-ils.storage.actor.org_unit.closed_date.overlap',
3014 $hold->pickup_lib, $expire_time);
3017 my $dt_parser = DateTime::Format::ISO8601->new;
3018 $expire_time = $dt_parser->parse_datetime(cleanse_ISO8601($dateinfo->{end}));
3020 # TODO: enable/disable time bump via setting?
3021 $expire_time->set(hour => '23', minute => '59', second => '59');
3023 $logger->info("circulator: shelf_expire_time overlaps".
3024 " with closed date, pushing expire time to $expire_time");
3027 $hold->shelf_expire_time($expire_time->strftime('%FT%T%z'));
3033 sub generate_fines {
3035 my $reservation = shift;
3037 $self->generate_fines_start($reservation);
3038 $self->generate_fines_finish($reservation);
3043 sub generate_fines_start {
3045 my $reservation = shift;
3046 my $dt_parser = DateTime::Format::ISO8601->new;
3048 my $obj = $reservation ? $self->reservation : $self->circ;
3050 # If we have a grace period
3051 if($obj->can('grace_period')) {
3052 # Parse out the due date
3053 my $due_date = $dt_parser->parse_datetime( cleanse_ISO8601($obj->due_date) );
3054 # Add the grace period to the due date
3055 $due_date->add(seconds => OpenSRF::Utils->interval_to_seconds($obj->grace_period));
3056 # Don't generate fines on circs still in grace period
3057 return undef if ($due_date > DateTime->now);
3060 if (!exists($self->{_gen_fines_req})) {
3061 $self->{_gen_fines_req} = OpenSRF::AppSession->create('open-ils.storage')
3063 'open-ils.storage.action.circulation.overdue.generate_fines',
3071 sub generate_fines_finish {
3073 my $reservation = shift;
3075 return undef unless $self->{_gen_fines_req};
3077 my $id = $reservation ? $self->reservation->id : $self->circ->id;
3079 $self->{_gen_fines_req}->wait_complete;
3080 delete($self->{_gen_fines_req});
3082 # refresh the circ in case the fine generator set the stop_fines field
3083 $self->reservation($self->editor->retrieve_booking_reservation($id)) if $reservation;
3084 $self->circ($self->editor->retrieve_action_circulation($id)) if !$reservation;
3089 sub checkin_handle_circ {
3091 my $circ = $self->circ;
3092 my $copy = $self->copy;
3096 $self->backdate($circ->xact_start) if $self->claims_never_checked_out;
3098 # backdate the circ if necessary
3099 if($self->backdate) {
3100 my $evt = $self->checkin_handle_backdate;
3101 return $self->bail_on_events($evt) if $evt;
3104 if(!$circ->stop_fines) {
3105 $circ->stop_fines(OILS_STOP_FINES_CHECKIN);
3106 $circ->stop_fines(OILS_STOP_FINES_RENEW) if $self->is_renewal;
3107 $circ->stop_fines(OILS_STOP_FINES_CLAIMS_NEVERCHECKEDOUT) if $self->claims_never_checked_out;
3108 $circ->stop_fines_time('now');
3109 $circ->stop_fines_time($self->backdate) if $self->backdate;
3112 # Set the checkin vars since we have the item
3113 $circ->checkin_time( ($self->backdate) ? $self->backdate : 'now' );
3115 # capture the true scan time for back-dated checkins
3116 $circ->checkin_scan_time('now');
3118 $circ->checkin_staff($self->editor->requestor->id);
3119 $circ->checkin_lib($self->circ_lib);
3120 $circ->checkin_workstation($self->editor->requestor->wsid);
3122 my $circ_lib = (ref $self->copy->circ_lib) ?
3123 $self->copy->circ_lib->id : $self->copy->circ_lib;
3124 my $stat = $U->copy_status($self->copy->status)->id;
3126 # immediately available keeps items lost or missing items from going home before being handled
3127 my $lost_immediately_available = $U->ou_ancestor_setting_value(
3128 $circ_lib, OILS_SETTING_LOST_IMMEDIATELY_AVAILABLE, $self->editor) || 0;
3131 if ( (!$lost_immediately_available) && ($circ_lib != $self->circ_lib) ) {
3133 if( ($stat == OILS_COPY_STATUS_LOST or $stat == OILS_COPY_STATUS_MISSING) ) {
3134 $logger->info("circulator: not updating copy status on checkin because copy is lost/missing");
3136 $self->copy->status($U->copy_status(OILS_COPY_STATUS_RESHELVING));
3140 } elsif ($stat == OILS_COPY_STATUS_LOST) {
3142 $self->checkin_handle_lost($circ_lib);
3146 $self->copy->status($U->copy_status(OILS_COPY_STATUS_RESHELVING));
3151 # see if there are any fines owed on this circ. if not, close it
3152 ($obt) = $U->fetch_mbts($circ->id, $self->editor);
3153 $circ->xact_finish('now') if( $obt and $obt->balance_owed == 0 );
3155 $logger->debug("circulator: ".$obt->balance_owed." is owed on this circulation");
3157 return $self->bail_on_events($self->editor->event)
3158 unless $self->editor->update_action_circulation($circ);
3164 # ------------------------------------------------------------------
3165 # See if we need to void billings for lost checkin
3166 # ------------------------------------------------------------------
3167 sub checkin_handle_lost {
3169 my $circ_lib = shift;
3170 my $circ = $self->circ;
3172 my $max_return = $U->ou_ancestor_setting_value(
3173 $circ_lib, OILS_SETTING_MAX_ACCEPT_RETURN_OF_LOST, $self->editor) || 0;
3178 my @tm = reverse($circ->due_date =~ /([\d\.]+)/og);
3179 $tm[5] -= 1 if $tm[5] > 0;
3180 my $due = timelocal(int($tm[1]), int($tm[2]), int($tm[3]), int($tm[4]), int($tm[5]), int($tm[6]));
3182 my $last_chance = OpenSRF::Utils->interval_to_seconds($max_return) + int($due);
3183 $logger->info("MAX OD: ".$max_return." DUEDATE: ".$circ->due_date." TODAY: ".$today." DUE: ".$due." LAST: ".$last_chance);
3185 $max_return = 0 if $today < $last_chance;
3188 if (!$max_return){ # there's either no max time to accept returns defined or we're within that time
3190 my $void_lost = $U->ou_ancestor_setting_value(
3191 $circ_lib, OILS_SETTING_VOID_LOST_ON_CHECKIN, $self->editor) || 0;
3192 my $void_lost_fee = $U->ou_ancestor_setting_value(
3193 $circ_lib, OILS_SETTING_VOID_LOST_PROCESS_FEE_ON_CHECKIN, $self->editor) || 0;
3194 my $restore_od = $U->ou_ancestor_setting_value(
3195 $circ_lib, OILS_SETTING_RESTORE_OVERDUE_ON_LOST_RETURN, $self->editor) || 0;
3196 $self->generate_lost_overdue(1) if $U->ou_ancestor_setting_value(
3197 $circ_lib, OILS_SETTING_GENERATE_OVERDUE_ON_LOST_RETURN, $self->editor);
3199 $self->checkin_handle_lost_now_found(3) if $void_lost;
3200 $self->checkin_handle_lost_now_found(4) if $void_lost_fee;
3201 $self->checkin_handle_lost_now_found_restore_od($circ_lib) if $restore_od && ! $self->void_overdues;
3204 $self->copy->status($U->copy_status(OILS_COPY_STATUS_RESHELVING));
3209 sub checkin_handle_backdate {
3212 # ------------------------------------------------------------------
3213 # clean up the backdate for date comparison
3214 # XXX We are currently taking the due-time from the original due-date,
3215 # not the input. Do we need to do this? This certainly interferes with
3216 # backdating of hourly checkouts, but that is likely a very rare case.
3217 # ------------------------------------------------------------------
3218 my $bd = cleanse_ISO8601($self->backdate);
3219 my $original_date = DateTime::Format::ISO8601->new->parse_datetime(cleanse_ISO8601($self->circ->due_date));
3220 my $new_date = DateTime::Format::ISO8601->new->parse_datetime($bd);
3221 $bd = cleanse_ISO8601($new_date->ymd . 'T' . $original_date->strftime('%T%z'));
3223 $self->backdate($bd);
3228 sub check_checkin_copy_status {
3230 my $copy = $self->copy;
3232 my $status = $U->copy_status($copy->status)->id;
3235 if( $status == OILS_COPY_STATUS_AVAILABLE ||
3236 $status == OILS_COPY_STATUS_CHECKED_OUT ||
3237 $status == OILS_COPY_STATUS_IN_PROCESS ||
3238 $status == OILS_COPY_STATUS_ON_HOLDS_SHELF ||
3239 $status == OILS_COPY_STATUS_IN_TRANSIT ||
3240 $status == OILS_COPY_STATUS_CATALOGING ||
3241 $status == OILS_COPY_STATUS_ON_RESV_SHELF ||
3242 $status == OILS_COPY_STATUS_RESHELVING );
3244 return OpenILS::Event->new('COPY_STATUS_LOST', payload => $copy )
3245 if( $status == OILS_COPY_STATUS_LOST );
3247 return OpenILS::Event->new('COPY_STATUS_MISSING', payload => $copy )
3248 if( $status == OILS_COPY_STATUS_MISSING );
3250 return OpenILS::Event->new('COPY_BAD_STATUS', payload => $copy );
3255 # --------------------------------------------------------------------------
3256 # On checkin, we need to return as many relevant objects as we can
3257 # --------------------------------------------------------------------------
3258 sub checkin_flesh_events {
3261 if( grep { $_->{textcode} eq 'SUCCESS' } @{$self->events}
3262 and grep { $_->{textcode} eq 'ITEM_NOT_CATALOGED' } @{$self->events} ) {
3263 $self->events([grep { $_->{textcode} eq 'ITEM_NOT_CATALOGED' } @{$self->events}]);
3266 my $record = $U->record_to_mvr($self->title) if($self->title and !$self->is_precat);
3269 if($self->hold and !$self->hold->cancel_time) {
3270 $hold = $self->hold;
3271 $hold->notes($self->editor->search_action_hold_request_note({hold => $hold->id}));
3275 # if we checked in a circulation, flesh the billing summary data
3276 $self->circ->billable_transaction(
3277 $self->editor->retrieve_money_billable_transaction([
3279 {flesh => 1, flesh_fields => {mbt => ['summary']}}
3285 # flesh some patron fields before returning
3287 $self->editor->retrieve_actor_user([
3292 au => ['card', 'billing_address', 'mailing_address']
3299 for my $evt (@{$self->events}) {
3302 $payload->{copy} = $U->unflesh_copy($self->copy);
3303 $payload->{volume} = $self->volume;
3304 $payload->{record} = $record,
3305 $payload->{circ} = $self->circ;
3306 $payload->{transit} = $self->transit;
3307 $payload->{cancelled_hold_transit} = 1 if $self->cancelled_hold_transit;
3308 $payload->{hold} = $hold;
3309 $payload->{patron} = $self->patron;
3310 $payload->{reservation} = $self->reservation
3311 unless (not $self->reservation or $self->reservation->cancel_time);
3313 $evt->{payload} = $payload;
3318 my( $self, $msg ) = @_;
3319 my $bc = ($self->copy) ? $self->copy->barcode :
3322 my $usr = ($self->patron) ? $self->patron->id : "";
3323 $logger->info("circulator: $msg requestor=".$self->editor->requestor->id.
3324 ", recipient=$usr, copy=$bc");
3330 $self->log_me("do_renew()");
3332 # Make sure there is an open circ to renew that is not
3333 # marked as LOST, CLAIMSRETURNED, or LONGOVERDUE
3334 my $usrid = $self->patron->id if $self->patron;
3335 my $circ = $self->editor->search_action_circulation({
3336 target_copy => $self->copy->id,
3337 xact_finish => undef,
3338 checkin_time => undef,
3339 ($usrid ? (usr => $usrid) : ()),
3341 {stop_fines => undef},
3342 {stop_fines => OILS_STOP_FINES_MAX_FINES}
3346 return $self->bail_on_events($self->editor->event) unless $circ;
3348 # A user is not allowed to renew another user's items without permission
3349 unless( $circ->usr eq $self->editor->requestor->id ) {
3350 return $self->bail_on_events($self->editor->events)
3351 unless $self->editor->allowed('RENEW_CIRC', $circ->circ_lib);
3354 $self->push_events(OpenILS::Event->new('MAX_RENEWALS_REACHED'))
3355 if $circ->renewal_remaining < 1;
3357 # -----------------------------------------------------------------
3359 $self->parent_circ($circ->id);
3360 $self->renewal_remaining( $circ->renewal_remaining - 1 );
3363 # Opac renewal - re-use circ library from original circ (unless told not to)
3364 if($self->opac_renewal) {
3365 unless(defined($opac_renewal_use_circ_lib)) {
3366 my $use_circ_lib = $self->editor->retrieve_config_global_flag('circ.opac_renewal.use_original_circ_lib');
3367 if($use_circ_lib and $U->is_true($use_circ_lib->enabled)) {
3368 $opac_renewal_use_circ_lib = 1;
3371 $opac_renewal_use_circ_lib = 0;
3374 $self->circ_lib($circ->circ_lib) if($opac_renewal_use_circ_lib);
3377 # Run the fine generator against the old circ
3378 $self->generate_fines_start;
3380 $self->run_renew_permit;
3383 $self->do_checkin();
3384 return if $self->bail_out;
3386 unless( $self->permit_override ) {
3388 return if $self->bail_out;
3389 $self->is_precat(1) if $self->have_event('ITEM_NOT_CATALOGED');
3390 $self->remove_event('ITEM_NOT_CATALOGED');
3393 $self->override_events;
3394 return if $self->bail_out;
3397 $self->do_checkout();
3402 my( $self, $evt ) = @_;
3403 $evt = (ref $evt) ? $evt->{textcode} : $evt;
3404 $logger->debug("circulator: removing event from list: $evt");
3405 my @events = @{$self->events};
3406 $self->events( [ grep { $_->{textcode} ne $evt } @events ] );
3411 my( $self, $evt ) = @_;
3412 $evt = (ref $evt) ? $evt->{textcode} : $evt;
3413 return grep { $_->{textcode} eq $evt } @{$self->events};
3418 sub run_renew_permit {
3421 if ($U->ou_ancestor_setting_value($self->circ_lib, 'circ.block_renews_for_holds')) {
3422 my ($hold, undef, $retarget) = $holdcode->find_nearest_permitted_hold(
3423 $self->editor, $self->copy, $self->editor->requestor, 1
3425 $self->push_events(new OpenILS::Event("COPY_NEEDED_FOR_HOLD")) if $hold;
3428 if(!$self->legacy_script_support) {
3429 my $results = $self->run_indb_circ_test;
3430 $self->push_events($self->matrix_test_result_events)
3431 unless $self->circ_test_success;
3434 my $runner = $self->script_runner;
3436 $runner->load($self->circ_permit_renew);
3437 my $result = $runner->run or
3438 throw OpenSRF::EX::ERROR ("Circ Permit Renew Script Died: $@");
3439 if ($result->{"events"}) {
3441 map { new OpenILS::Event($_) } @{$result->{"events"}}
3444 "circulator: circ_permit_renew for user " .
3445 $self->patron->id . " returned " .
3446 scalar(@{$result->{"events"}}) . " event(s)"
3450 $self->mk_script_runner;
3453 $logger->debug("circulator: re-creating script runner to be safe");
3457 # XXX: The primary mechanism for storing circ history is now handled
3458 # by tracking real circulation objects instead of bibs in a bucket.
3459 # However, this code is disabled by default and could be useful
3460 # some day, so may as well leave it for now.
3461 sub append_reading_list {
3465 $self->is_checkout and
3471 # verify history is globally enabled and uses the bucket mechanism
3472 my $htype = OpenSRF::Utils::SettingsClient->new->config_value(
3473 apps => 'open-ils.circ' => app_settings => 'checkout_history_mechanism');
3475 return undef unless $htype and $htype eq 'bucket';
3477 my $e = new_editor(xact => 1, requestor => $self->editor->requestor);
3479 # verify the patron wants to retain the hisory
3480 my $setting = $e->search_actor_user_setting(
3481 {usr => $self->patron->id, name => 'circ.keep_checkout_history'})->[0];
3483 unless($setting and $setting->value) {
3488 my $bkt = $e->search_container_copy_bucket(
3489 {owner => $self->patron->id, btype => 'circ_history'})->[0];
3494 # find the next item position
3495 my $last_item = $e->search_container_copy_bucket_item(
3496 {bucket => $bkt->id}, {order_by => {ccbi => 'pos desc'}, limit => 1})->[0];
3497 $pos = $last_item->pos + 1 if $last_item;
3500 # create the history bucket if necessary
3501 $bkt = Fieldmapper::container::copy_bucket->new;
3502 $bkt->owner($self->patron->id);
3504 $bkt->btype('circ_history');
3506 $e->create_container_copy_bucket($bkt) or return $e->die_event;
3509 my $item = Fieldmapper::container::copy_bucket_item->new;
3511 $item->bucket($bkt->id);
3512 $item->target_copy($self->copy->id);
3515 $e->create_container_copy_bucket_item($item) or return $e->die_event;
3522 sub make_trigger_events {
3524 return unless $self->circ;
3525 $U->create_events_for_hook('checkout', $self->circ, $self->circ_lib) if $self->is_checkout;
3526 $U->create_events_for_hook('checkin', $self->circ, $self->circ_lib) if $self->is_checkin;
3527 $U->create_events_for_hook('renewal', $self->circ, $self->circ_lib) if $self->is_renewal;
3532 sub checkin_handle_lost_now_found {
3533 my ($self, $bill_type) = @_;
3535 # ------------------------------------------------------------------
3536 # remove charge from patron's account if lost item is returned
3537 # ------------------------------------------------------------------
3539 my $bills = $self->editor->search_money_billing(
3541 xact => $self->circ->id,
3546 $logger->debug("voiding lost item charge of ".scalar(@$bills));
3547 for my $bill (@$bills) {
3548 if( !$U->is_true($bill->voided) ) {
3549 $logger->info("lost item returned - voiding bill ".$bill->id);
3551 $bill->void_time('now');
3552 $bill->voider($self->editor->requestor->id);
3553 my $note = ($bill->note) ? $bill->note . "\n" : '';
3554 $bill->note("${note}System: VOIDED FOR LOST ITEM RETURNED");
3556 $self->bail_on_events($self->editor->event)
3557 unless $self->editor->update_money_billing($bill);
3562 sub checkin_handle_lost_now_found_restore_od {
3564 my $circ_lib = shift;
3566 # ------------------------------------------------------------------
3567 # restore those overdue charges voided when item was set to lost
3568 # ------------------------------------------------------------------
3570 my $ods = $self->editor->search_money_billing(
3572 xact => $self->circ->id,
3577 $logger->debug("returning overdue charges pre-lost ".scalar(@$ods));
3578 for my $bill (@$ods) {
3579 if( $U->is_true($bill->voided) ) {
3580 $logger->info("lost item returned - restoring overdue ".$bill->id);
3582 $bill->clear_void_time;
3583 $bill->voider($self->editor->requestor->id);
3584 my $note = ($bill->note) ? $bill->note . "\n" : '';
3585 $bill->note("${note}System: LOST RETURNED - OVERDUES REINSTATED");
3587 $self->bail_on_events($self->editor->event)
3588 unless $self->editor->update_money_billing($bill);
3593 # ------------------------------------------------------------------
3594 # Lost-then-found item checked in. This sub generates new overdue
3595 # fines, beyond the point of any existing and possibly voided
3596 # overdue fines, up to the point of final checkin time (or max fine
3598 # ------------------------------------------------------------------
3599 sub generate_lost_overdue_fines {
3601 my $circ = $self->circ;
3602 my $e = $self->editor;
3604 # Re-open the transaction so the fine generator can see it
3605 if($circ->xact_finish or $circ->stop_fines) {
3607 $circ->clear_xact_finish;
3608 $circ->clear_stop_fines;
3609 $circ->clear_stop_fines_time;
3610 $e->update_action_circulation($circ) or return $e->die_event;
3614 $e->xact_begin; # generate_fines expects an in-xact editor
3615 $self->generate_fines;
3616 $circ = $self->circ; # generate fines re-fetches the circ
3620 # Re-close the transaction if no money is owed
3621 my ($obt) = $U->fetch_mbts($circ->id, $e);
3622 if ($obt and $obt->balance_owed == 0) {
3623 $circ->xact_finish('now');
3627 # Set stop fines if the fine generator didn't have to
3628 unless($circ->stop_fines) {
3629 $circ->stop_fines(OILS_STOP_FINES_CHECKIN);
3630 $circ->stop_fines_time('now');
3634 # update the event data sent to the caller within the transaction
3635 $self->checkin_flesh_events;
3638 $e->update_action_circulation($circ) or return $e->die_event;