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
545 my $type = ref($self) or die "$self is not an object";
547 my $name = $AUTOLOAD;
550 unless (grep { $_ eq $name } @AUTOLOAD_FIELDS) {
551 $logger->error("circulator: $type: invalid autoload field: $name");
552 die "$type: invalid autoload field: $name\n"
557 *{"${type}::${name}"} = sub {
560 $s->{$name} = $v if defined $v;
564 return $self->$name($data);
569 my( $class, $auth, %args ) = @_;
570 $class = ref($class) || $class;
571 my $self = bless( {}, $class );
574 $self->editor(new_editor(xact => 1, authtoken => $auth));
576 unless( $self->editor->checkauth ) {
577 $self->bail_on_events($self->editor->event);
581 $self->cache_handle(OpenSRF::Utils::Cache->new('global'));
583 $self->$_($args{$_}) for keys %args;
586 ($self->circ_lib) ? $self->circ_lib : $self->editor->requestor->ws_ou);
588 # if this is a renewal, default to desk_renewal
589 $self->desk_renewal(1) unless
590 $self->opac_renewal or $self->phone_renewal or $self->sip_renewal;
592 $self->capture('') unless $self->capture;
594 unless(%user_groups) {
595 my $gps = $self->editor->retrieve_all_permission_grp_tree;
596 %user_groups = map { $_->id => $_ } @$gps;
603 # --------------------------------------------------------------------------
604 # True if we should discontinue processing
605 # --------------------------------------------------------------------------
607 my( $self, $bool ) = @_;
608 if( defined $bool ) {
609 $logger->info("circulator: BAILING OUT") if $bool;
610 $self->{bail_out} = $bool;
612 return $self->{bail_out};
617 my( $self, @evts ) = @_;
620 $e->{payload} = $self->copy if
621 ($e->{textcode} eq 'COPY_NOT_AVAILABLE');
623 $logger->info("circulator: pushing event ".$e->{textcode});
624 push( @{$self->events}, $e ) unless
625 grep { $_->{textcode} eq $e->{textcode} } @{$self->events};
631 return '' if $self->skip_permit_key;
632 my $key = md5_hex( time() . rand() . "$$" );
633 $self->cache_handle->put_cache( "oils_permit_key_$key", 1, 300 );
634 return $self->permit_key($key);
637 sub check_permit_key {
639 return 1 if $self->skip_permit_key;
640 my $key = $self->permit_key;
641 return 0 unless $key;
642 my $k = "oils_permit_key_$key";
643 my $one = $self->cache_handle->get_cache($k);
644 $self->cache_handle->delete_cache($k);
645 return ($one) ? 1 : 0;
648 sub seems_like_reservation {
651 # Some words about the following method:
652 # 1) It requires the VIEW_USER permission, but that's not an
653 # issue, right, since all staff should have that?
654 # 2) It returns only one reservation at a time, even if an item can be
655 # and is currently overbooked. Hmmm....
656 my $booking_ses = create OpenSRF::AppSession("open-ils.booking");
657 my $result = $booking_ses->request(
658 "open-ils.booking.reservations.by_returnable_resource_barcode",
659 $self->editor->authtoken,
662 $booking_ses->disconnect;
664 return $self->bail_on_events($result) if defined $U->event_code($result);
667 $self->reservation(shift @$result);
675 # save_trimmed_copy() used just to be a block in mk_env(), but was separated for re-use
676 sub save_trimmed_copy {
677 my ($self, $copy) = @_;
680 $self->volume($copy->call_number);
681 $self->title($self->volume->record);
682 $self->copy->call_number($self->volume->id);
683 $self->volume->record($self->title->id);
684 $self->is_precat(1) if $self->volume->id == OILS_PRECAT_CALL_NUMBER;
685 if($self->copy->deposit_amount and $self->copy->deposit_amount > 0) {
686 $self->is_deposit(1) if $U->is_true($self->copy->deposit);
687 $self->is_rental(1) unless $U->is_true($self->copy->deposit);
693 my $e = $self->editor;
695 # --------------------------------------------------------------------------
696 # Grab the fleshed copy
697 # --------------------------------------------------------------------------
698 unless($self->is_noncat) {
701 $copy = $e->retrieve_asset_copy(
702 [$self->copy_id, $MK_ENV_FLESH ]) or return $e->event;
704 } elsif( $self->copy_barcode ) {
706 $copy = $e->search_asset_copy(
707 [{barcode => $self->copy_barcode, deleted => 'f'}, $MK_ENV_FLESH ])->[0];
708 } elsif( $self->reservation ) {
709 my $res = $e->json_query(
711 "select" => {"acp" => ["id"]},
716 "field" => "barcode",
720 "field" => "current_resource"
728 "id" => (ref $self->reservation) ?
729 $self->reservation->id : $self->reservation
734 if (ref $res eq "ARRAY" and scalar @$res) {
735 $logger->info("circulator: mapped reservation " .
736 $self->reservation . " to copy " . $res->[0]->{"id"});
737 $copy = $e->retrieve_asset_copy([$res->[0]->{"id"}, $MK_ENV_FLESH]);
742 $self->save_trimmed_copy($copy);
744 # We can't renew if there is no copy
745 return $self->bail_on_events(OpenILS::Event->new('ASSET_COPY_NOT_FOUND'))
746 if $self->is_renewal;
751 # --------------------------------------------------------------------------
753 # --------------------------------------------------------------------------
757 flesh_fields => {au => [ qw/ card / ]}
760 if( $self->patron_id ) {
761 $patron = $e->retrieve_actor_user([$self->patron_id, $flesh])
762 or return $self->bail_on_events(OpenILS::Event->new('ACTOR_USER_NOT_FOUND'));
764 } elsif( $self->patron_barcode ) {
766 # note: throwing ACTOR_USER_NOT_FOUND instead of ACTOR_CARD_NOT_FOUND is intentional
767 my $card = $e->search_actor_card({barcode => $self->patron_barcode})->[0]
768 or return $self->bail_on_events(OpenILS::Event->new('ACTOR_USER_NOT_FOUND'));
770 $patron = $e->retrieve_actor_user($card->usr)
771 or return $self->bail_on_events(OpenILS::Event->new('ACTOR_USER_NOT_FOUND'));
773 # Use the card we looked up, not the patron's primary, for card active checks
774 $patron->card($card);
777 if( my $copy = $self->copy ) {
780 $flesh->{flesh_fields}->{circ} = ['usr'];
782 my $circ = $e->search_action_circulation([
783 {target_copy => $copy->id, checkin_time => undef}, $flesh
787 $patron = $circ->usr;
788 $circ->usr($patron->id); # de-flesh for consistency
794 return $self->bail_on_events(OpenILS::Event->new('ACTOR_USER_NOT_FOUND'))
795 unless $self->patron($patron) or $self->is_checkin;
797 unless($self->is_checkin) {
799 # Check for inactivity and patron reg. expiration
801 $self->bail_on_events(OpenILS::Event->new('PATRON_INACTIVE'))
802 unless $U->is_true($patron->active);
804 $self->bail_on_events(OpenILS::Event->new('PATRON_CARD_INACTIVE'))
805 unless $U->is_true($patron->card->active);
807 my $expire = DateTime::Format::ISO8601->new->parse_datetime(
808 cleanse_ISO8601($patron->expire_date));
810 $self->bail_on_events(OpenILS::Event->new('PATRON_ACCOUNT_EXPIRED'))
811 if( CORE::time > $expire->epoch ) ;
815 # --------------------------------------------------------------------------
816 # This builds the script runner environment and fetches most of the
818 # --------------------------------------------------------------------------
819 sub mk_script_runner {
825 qw/copy copy_barcode copy_id patron
826 patron_id patron_barcode volume title editor/;
828 # Translate our objects into the ScriptBuilder args hash
829 $$args{$_} = $self->$_() for @fields;
831 $args->{ignore_user_status} = 1 if $self->is_checkin;
832 $$args{fetch_patron_by_circ_copy} = 1;
833 $$args{fetch_patron_circ_info} = 1 unless $self->is_checkin;
835 if( my $pco = $self->pending_checkouts ) {
836 $logger->info("circulator: we were given a pending checkouts number of $pco");
837 $$args{patronItemsOut} = $pco;
840 # This fetches most of the objects we need
841 $self->script_runner(
842 OpenILS::Application::Circ::ScriptBuilder->build($args));
844 # Now we translate the ScriptBuilder objects back into self
845 $self->$_($$args{$_}) for @fields;
847 my @evts = @{$args->{_events}} if $args->{_events};
849 $logger->debug("circulator: script builder returned events: @evts") if @evts;
853 # Anything besides ASSET_COPY_NOT_FOUND will stop processing
854 if(!$self->is_noncat and
856 $evts[0]->{textcode} eq 'ASSET_COPY_NOT_FOUND') {
860 my @e = grep { $_->{textcode} ne 'ASSET_COPY_NOT_FOUND' } @evts;
861 return $self->bail_on_events(@e);
866 $self->is_precat(1) if $self->copy->call_number == OILS_PRECAT_CALL_NUMBER;
867 if($self->copy->deposit_amount and $self->copy->deposit_amount > 0) {
868 $self->is_deposit(1) if $U->is_true($self->copy->deposit);
869 $self->is_rental(1) unless $U->is_true($self->copy->deposit);
873 # We can't renew if there is no copy
874 return $self->bail_on_events(@evts) if
875 $self->is_renewal and !$self->copy;
877 # Set some circ-specific flags in the script environment
878 my $evt = "environment";
879 $self->script_runner->insert("$evt.isRenewal", ($self->is_renewal) ? 1 : undef);
881 if( $self->is_noncat ) {
882 $self->script_runner->insert("$evt.isNonCat", 1);
883 $self->script_runner->insert("$evt.nonCatType", $self->noncat_type);
886 if( $self->is_precat ) {
887 $self->script_runner->insert("environment.isPrecat", 1, 1);
890 $self->script_runner->add_path( $_ ) for @$script_libs;
895 # --------------------------------------------------------------------------
896 # Does the circ permit work
897 # --------------------------------------------------------------------------
901 $self->log_me("do_permit()");
903 unless( $self->editor->requestor->id == $self->patron->id ) {
904 return $self->bail_on_events($self->editor->event)
905 unless( $self->editor->allowed('VIEW_PERMIT_CHECKOUT') );
908 $self->check_captured_holds();
909 $self->do_copy_checks();
910 return if $self->bail_out;
911 $self->run_patron_permit_scripts();
912 $self->run_copy_permit_scripts()
913 unless $self->is_precat or $self->is_noncat;
914 $self->check_item_deposit_events();
915 $self->override_events();
916 return if $self->bail_out;
918 if($self->is_precat and not $self->request_precat) {
921 'ITEM_NOT_CATALOGED', payload => $self->mk_permit_key));
922 return $self->bail_out(1) unless $self->is_renewal;
926 OpenILS::Event->new('SUCCESS', payload => $self->mk_permit_key));
929 sub check_item_deposit_events {
931 $self->push_events(OpenILS::Event->new('ITEM_DEPOSIT_REQUIRED', payload => $self->copy))
932 if $self->is_deposit and not $self->is_deposit_exempt;
933 $self->push_events(OpenILS::Event->new('ITEM_RENTAL_FEE_REQUIRED', payload => $self->copy))
934 if $self->is_rental and not $self->is_rental_exempt;
937 # returns true if the user is not required to pay deposits
938 sub is_deposit_exempt {
940 my $pid = (ref $self->patron->profile) ?
941 $self->patron->profile->id : $self->patron->profile;
942 my $groups = $U->ou_ancestor_setting_value(
943 $self->circ_lib, 'circ.deposit.exempt_groups', $self->editor);
944 for my $grp (@$groups) {
945 return 1 if $self->is_group_descendant($grp, $pid);
950 # returns true if the user is not required to pay rental fees
951 sub is_rental_exempt {
953 my $pid = (ref $self->patron->profile) ?
954 $self->patron->profile->id : $self->patron->profile;
955 my $groups = $U->ou_ancestor_setting_value(
956 $self->circ_lib, 'circ.rental.exempt_groups', $self->editor);
957 for my $grp (@$groups) {
958 return 1 if $self->is_group_descendant($grp, $pid);
963 sub is_group_descendant {
964 my($self, $p_id, $c_id) = @_;
965 return 0 unless defined $p_id and defined $c_id;
966 return 1 if $c_id == $p_id;
967 while(my $grp = $user_groups{$c_id}) {
968 $c_id = $grp->parent;
969 return 0 unless defined $c_id;
970 return 1 if $c_id == $p_id;
975 sub check_captured_holds {
977 my $copy = $self->copy;
978 my $patron = $self->patron;
980 return undef unless $copy;
982 my $s = $U->copy_status($copy->status)->id;
983 return unless $s == OILS_COPY_STATUS_ON_HOLDS_SHELF;
984 $logger->info("circulator: copy is on holds shelf, searching for the correct hold");
986 # Item is on the holds shelf, make sure it's going to the right person
987 my $holds = $self->editor->search_action_hold_request(
990 current_copy => $copy->id ,
991 capture_time => { '!=' => undef },
992 cancel_time => undef,
993 fulfillment_time => undef
999 if( $holds and $$holds[0] ) {
1000 return undef if $$holds[0]->usr == $patron->id;
1003 $logger->info("circulator: this copy is needed by a different patron to fulfill a hold");
1005 $self->push_events(OpenILS::Event->new('ITEM_ON_HOLDS_SHELF'));
1009 sub do_copy_checks {
1011 my $copy = $self->copy;
1012 return unless $copy;
1014 my $stat = $U->copy_status($copy->status)->id;
1016 # We cannot check out a copy if it is in-transit
1017 if( $stat == OILS_COPY_STATUS_IN_TRANSIT ) {
1018 return $self->bail_on_events(OpenILS::Event->new('COPY_IN_TRANSIT'));
1021 $self->handle_claims_returned();
1022 return if $self->bail_out;
1024 # no claims returned circ was found, check if there is any open circ
1025 unless( $self->is_renewal ) {
1027 my $circs = $self->editor->search_action_circulation(
1028 { target_copy => $copy->id, checkin_time => undef }
1031 if(my $old_circ = $circs->[0]) { # an open circ was found
1033 my $payload = {copy => $copy};
1035 if($old_circ->usr == $self->patron->id) {
1037 $payload->{old_circ} = $old_circ;
1039 # If there is an open circulation on the checkout item and an auto-renew
1040 # interval is defined, inform the caller that they should go
1041 # ahead and renew the item instead of warning about open circulations.
1043 my $auto_renew_intvl = $U->ou_ancestor_setting_value(
1045 'circ.checkout_auto_renew_age',
1049 if($auto_renew_intvl) {
1050 my $intvl_seconds = OpenSRF::Utils->interval_to_seconds($auto_renew_intvl);
1051 my $checkout_time = DateTime::Format::ISO8601->new->parse_datetime( cleanse_ISO8601($old_circ->xact_start) );
1053 if(DateTime->now > $checkout_time->add(seconds => $intvl_seconds)) {
1054 $payload->{auto_renew} = 1;
1059 return $self->bail_on_events(
1060 OpenILS::Event->new('OPEN_CIRCULATION_EXISTS', payload => $payload)
1066 my $LEGACY_CIRC_EVENT_MAP = {
1067 'no_item' => 'ITEM_NOT_CATALOGED',
1068 'actor.usr.barred' => 'PATRON_BARRED',
1069 'asset.copy.circulate' => 'COPY_CIRC_NOT_ALLOWED',
1070 'asset.copy.status' => 'COPY_NOT_AVAILABLE',
1071 'asset.copy_location.circulate' => 'COPY_CIRC_NOT_ALLOWED',
1072 'config.circ_matrix_test.circulate' => 'COPY_CIRC_NOT_ALLOWED',
1073 'config.circ_matrix_test.max_items_out' => 'PATRON_EXCEEDS_CHECKOUT_COUNT',
1074 'config.circ_matrix_test.max_overdue' => 'PATRON_EXCEEDS_OVERDUE_COUNT',
1075 'config.circ_matrix_test.max_fines' => 'PATRON_EXCEEDS_FINES',
1076 'config.circ_matrix_circ_mod_test' => 'PATRON_EXCEEDS_CHECKOUT_COUNT',
1080 # ---------------------------------------------------------------------
1081 # This pushes any patron-related events into the list but does not
1082 # set bail_out for any events
1083 # ---------------------------------------------------------------------
1084 sub run_patron_permit_scripts {
1086 my $runner = $self->script_runner;
1087 my $patronid = $self->patron->id;
1091 if(!$self->legacy_script_support) {
1093 my $results = $self->run_indb_circ_test;
1094 unless($self->circ_test_success) {
1095 # no_item result is OK during noncat checkout
1096 unless(@$results == 1 && $results->[0]->{fail_part} eq 'no_item' and $self->is_noncat) {
1097 push @allevents, $self->matrix_test_result_events;
1103 # ---------------------------------------------------------------------
1104 # # Now run the patron permit script
1105 # ---------------------------------------------------------------------
1106 $runner->load($self->circ_permit_patron);
1107 my $result = $runner->run or
1108 throw OpenSRF::EX::ERROR ("Circ Permit Patron Script Died: $@");
1110 my $patron_events = $result->{events};
1112 OpenILS::Utils::Penalty->calculate_penalties($self->editor, $self->patron->id, $self->circ_lib);
1113 my $mask = ($self->is_renewal) ? 'RENEW' : 'CIRC';
1114 my $penalties = OpenILS::Utils::Penalty->retrieve_penalties($self->editor, $patronid, $self->circ_lib, $mask);
1115 $penalties = $penalties->{fatal_penalties};
1117 for my $pen (@$penalties) {
1118 my $event = OpenILS::Event->new($pen->name);
1119 $event->{desc} = $pen->label;
1120 push(@allevents, $event);
1123 push(@allevents, OpenILS::Event->new($_)) for (@$patron_events);
1127 $_->{payload} = $self->copy if
1128 ($_->{textcode} eq 'COPY_NOT_AVAILABLE');
1131 $logger->info("circulator: permit_patron script returned events: @allevents") if @allevents;
1133 $self->push_events(@allevents);
1136 sub matrix_test_result_codes {
1138 map { $_->{"fail_part"} } @{$self->matrix_test_result};
1141 sub matrix_test_result_events {
1144 my $event = new OpenILS::Event(
1145 $LEGACY_CIRC_EVENT_MAP->{$_->{"fail_part"}} || $_->{"fail_part"}
1147 $event->{"payload"} = {"fail_part" => $_->{"fail_part"}};
1149 } (@{$self->matrix_test_result});
1152 sub run_indb_circ_test {
1154 return $self->matrix_test_result if $self->matrix_test_result;
1156 my $dbfunc = ($self->is_renewal) ?
1157 'action.item_user_renew_test' : 'action.item_user_circ_test';
1159 if( $self->is_precat && $self->request_precat) {
1160 $self->make_precat_copy;
1161 return if $self->bail_out;
1164 my $results = $self->editor->json_query(
1168 ($self->is_noncat or ($self->is_precat and !$self->override and !$self->is_renewal)) ? undef : $self->copy->id,
1174 $self->circ_test_success($U->is_true($results->[0]->{success}));
1176 if(my $mp = $results->[0]->{matchpoint}) {
1177 $logger->info("circulator: circ policy test found matchpoint built via rows " . $results->[0]->{buildrows});
1178 $self->circ_matrix_matchpoint($self->editor->retrieve_config_circ_matrix_matchpoint($mp));
1179 $self->circ_matrix_matchpoint->duration_rule($self->editor->retrieve_config_rules_circ_duration($results->[0]->{duration_rule}));
1180 if(defined($results->[0]->{renewals})) {
1181 $self->circ_matrix_matchpoint->duration_rule->max_renewals($results->[0]->{renewals});
1183 $self->circ_matrix_matchpoint->recurring_fine_rule($self->editor->retrieve_config_rules_recurring_fine($results->[0]->{recurring_fine_rule}));
1184 if(defined($results->[0]->{grace_period})) {
1185 $self->circ_matrix_matchpoint->recurring_fine_rule->grace_period($results->[0]->{grace_period});
1187 $self->circ_matrix_matchpoint->max_fine_rule($self->editor->retrieve_config_rules_max_fine($results->[0]->{max_fine_rule}));
1188 $self->circ_matrix_matchpoint->hard_due_date($self->editor->retrieve_config_hard_due_date($results->[0]->{hard_due_date}));
1191 return $self->matrix_test_result($results);
1194 # ---------------------------------------------------------------------
1195 # given a use and copy, this will calculate the circulation policy
1196 # parameters. Only works with in-db circ.
1197 # ---------------------------------------------------------------------
1201 return OpenILS::Event->new('ASSET_COPY_NOT_FOUND') unless $self->copy;
1203 $self->run_indb_circ_test;
1206 circ_test_success => $self->circ_test_success,
1207 failure_events => [],
1208 failure_codes => [],
1209 matchpoint => $self->circ_matrix_matchpoint
1212 unless($self->circ_test_success) {
1213 $results->{"failure_codes"} = [ $self->matrix_test_result_codes ];
1214 $results->{"failure_events"} = [ $self->matrix_test_result_events ];
1217 if($self->circ_matrix_matchpoint) {
1218 my $duration_rule = $self->circ_matrix_matchpoint->duration_rule;
1219 my $recurring_fine_rule = $self->circ_matrix_matchpoint->recurring_fine_rule;
1220 my $max_fine_rule = $self->circ_matrix_matchpoint->max_fine_rule;
1221 my $hard_due_date = $self->circ_matrix_matchpoint->hard_due_date;
1223 my $policy = $self->get_circ_policy(
1224 $duration_rule, $recurring_fine_rule, $max_fine_rule, $hard_due_date);
1226 $$results{$_} = $$policy{$_} for keys %$policy;
1232 # ---------------------------------------------------------------------
1233 # Loads the circ policy info for duration, recurring fine, and max
1234 # fine based on the current copy
1235 # ---------------------------------------------------------------------
1236 sub get_circ_policy {
1237 my($self, $duration_rule, $recurring_fine_rule, $max_fine_rule, $hard_due_date) = @_;
1240 duration_rule => $duration_rule->name,
1241 recurring_fine_rule => $recurring_fine_rule->name,
1242 max_fine_rule => $max_fine_rule->name,
1243 max_fine => $self->get_max_fine_amount($max_fine_rule),
1244 fine_interval => $recurring_fine_rule->recurrence_interval,
1245 renewal_remaining => $duration_rule->max_renewals,
1246 grace_period => $recurring_fine_rule->grace_period
1249 if($hard_due_date) {
1250 $policy->{duration_date_ceiling} = $hard_due_date->ceiling_date;
1251 $policy->{duration_date_ceiling_force} = $hard_due_date->forceto;
1254 $policy->{duration_date_ceiling} = undef;
1255 $policy->{duration_date_ceiling_force} = undef;
1258 $policy->{duration} = $duration_rule->shrt
1259 if $self->copy->loan_duration == OILS_CIRC_DURATION_SHORT;
1260 $policy->{duration} = $duration_rule->normal
1261 if $self->copy->loan_duration == OILS_CIRC_DURATION_NORMAL;
1262 $policy->{duration} = $duration_rule->extended
1263 if $self->copy->loan_duration == OILS_CIRC_DURATION_EXTENDED;
1265 $policy->{recurring_fine} = $recurring_fine_rule->low
1266 if $self->copy->fine_level == OILS_REC_FINE_LEVEL_LOW;
1267 $policy->{recurring_fine} = $recurring_fine_rule->normal
1268 if $self->copy->fine_level == OILS_REC_FINE_LEVEL_NORMAL;
1269 $policy->{recurring_fine} = $recurring_fine_rule->high
1270 if $self->copy->fine_level == OILS_REC_FINE_LEVEL_HIGH;
1275 sub get_max_fine_amount {
1277 my $max_fine_rule = shift;
1278 my $max_amount = $max_fine_rule->amount;
1280 # if is_percent is true then the max->amount is
1281 # use as a percentage of the copy price
1282 if ($U->is_true($max_fine_rule->is_percent)) {
1283 my $price = $U->get_copy_price($self->editor, $self->copy, $self->volume);
1284 $max_amount = $price * $max_fine_rule->amount / 100;
1286 $U->ou_ancestor_setting_value(
1288 'circ.max_fine.cap_at_price',
1292 my $price = $U->get_copy_price($self->editor, $self->copy, $self->volume);
1293 $max_amount = ( $price && $max_amount > $price ) ? $price : $max_amount;
1301 sub run_copy_permit_scripts {
1303 my $copy = $self->copy || return;
1304 my $runner = $self->script_runner;
1308 if(!$self->legacy_script_support) {
1309 my $results = $self->run_indb_circ_test;
1310 push @allevents, $self->matrix_test_result_events
1311 unless $self->circ_test_success;
1314 # ---------------------------------------------------------------------
1315 # Capture all of the copy permit events
1316 # ---------------------------------------------------------------------
1317 $runner->load($self->circ_permit_copy);
1318 my $result = $runner->run or
1319 throw OpenSRF::EX::ERROR ("Circ Permit Copy Script Died: $@");
1320 my $copy_events = $result->{events};
1322 # ---------------------------------------------------------------------
1323 # Now collect all of the events together
1324 # ---------------------------------------------------------------------
1325 push( @allevents, OpenILS::Event->new($_)) for @$copy_events;
1328 # See if this copy has an alert message
1329 my $ae = $self->check_copy_alert();
1330 push( @allevents, $ae ) if $ae;
1332 # uniquify the events
1333 my %hash = map { ($_->{ilsevent} => $_) } @allevents;
1334 @allevents = values %hash;
1336 $logger->info("circulator: permit_copy script returned events: @allevents") if @allevents;
1338 $self->push_events(@allevents);
1342 sub check_copy_alert {
1344 return undef if $self->is_renewal;
1345 return OpenILS::Event->new(
1346 'COPY_ALERT_MESSAGE', payload => $self->copy->alert_message)
1347 if $self->copy and $self->copy->alert_message;
1353 # --------------------------------------------------------------------------
1354 # If the call is overriding and has permissions to override every collected
1355 # event, the are cleared. Any event that the caller does not have
1356 # permission to override, will be left in the event list and bail_out will
1358 # XXX We need code in here to cancel any holds/transits on copies
1359 # that are being force-checked out
1360 # --------------------------------------------------------------------------
1361 sub override_events {
1363 my @events = @{$self->events};
1364 return unless @events;
1366 if(!$self->override) {
1367 return $self->bail_out(1)
1368 if( @events > 1 or $events[0]->{textcode} ne 'SUCCESS' );
1373 for my $e (@events) {
1374 my $tc = $e->{textcode};
1375 next if $tc eq 'SUCCESS';
1376 my $ov = "$tc.override";
1377 $logger->info("circulator: attempting to override event: $ov");
1379 return $self->bail_on_events($self->editor->event)
1380 unless( $self->editor->allowed($ov) );
1385 # --------------------------------------------------------------------------
1386 # If there is an open claimsreturn circ on the requested copy, close the
1387 # circ if overriding, otherwise bail out
1388 # --------------------------------------------------------------------------
1389 sub handle_claims_returned {
1391 my $copy = $self->copy;
1393 my $CR = $self->editor->search_action_circulation(
1395 target_copy => $copy->id,
1396 stop_fines => OILS_STOP_FINES_CLAIMSRETURNED,
1397 checkin_time => undef,
1401 return unless ($CR = $CR->[0]);
1405 # - If the caller has set the override flag, we will check the item in
1406 if($self->override) {
1408 $CR->checkin_time('now');
1409 $CR->checkin_scan_time('now');
1410 $CR->checkin_lib($self->circ_lib);
1411 $CR->checkin_workstation($self->editor->requestor->wsid);
1412 $CR->checkin_staff($self->editor->requestor->id);
1414 $evt = $self->editor->event
1415 unless $self->editor->update_action_circulation($CR);
1418 $evt = OpenILS::Event->new('CIRC_CLAIMS_RETURNED');
1421 $self->bail_on_events($evt) if $evt;
1426 # --------------------------------------------------------------------------
1427 # This performs the checkout
1428 # --------------------------------------------------------------------------
1432 $self->log_me("do_checkout()");
1434 # make sure perms are good if this isn't a renewal
1435 unless( $self->is_renewal ) {
1436 return $self->bail_on_events($self->editor->event)
1437 unless( $self->editor->allowed('COPY_CHECKOUT') );
1440 # verify the permit key
1441 unless( $self->check_permit_key ) {
1442 if( $self->permit_override ) {
1443 return $self->bail_on_events($self->editor->event)
1444 unless $self->editor->allowed('CIRC_PERMIT_OVERRIDE');
1446 return $self->bail_on_events(OpenILS::Event->new('CIRC_PERMIT_BAD_KEY'))
1450 # if this is a non-cataloged circ, build the circ and finish
1451 if( $self->is_noncat ) {
1452 $self->checkout_noncat;
1454 OpenILS::Event->new('SUCCESS',
1455 payload => { noncat_circ => $self->circ }));
1459 if( $self->is_precat ) {
1460 $self->make_precat_copy;
1461 return if $self->bail_out;
1463 } elsif( $self->copy->call_number == OILS_PRECAT_CALL_NUMBER ) {
1464 return $self->bail_on_events(OpenILS::Event->new('ITEM_NOT_CATALOGED'));
1467 $self->do_copy_checks;
1468 return if $self->bail_out;
1470 $self->run_checkout_scripts();
1471 return if $self->bail_out;
1473 $self->build_checkout_circ_object();
1474 return if $self->bail_out;
1476 my $modify_to_start = $self->booking_adjusted_due_date();
1477 return if $self->bail_out;
1479 $self->apply_modified_due_date($modify_to_start);
1480 return if $self->bail_out;
1482 return $self->bail_on_events($self->editor->event)
1483 unless $self->editor->create_action_circulation($self->circ);
1485 # refresh the circ to force local time zone for now
1486 $self->circ($self->editor->retrieve_action_circulation($self->circ->id));
1488 $self->copy->status(OILS_COPY_STATUS_CHECKED_OUT);
1490 return if $self->bail_out;
1492 $self->apply_deposit_fee();
1493 return if $self->bail_out;
1495 $self->handle_checkout_holds();
1496 return if $self->bail_out;
1498 # ------------------------------------------------------------------------------
1499 # Update the patron penalty info in the DB. Run it for permit-overrides
1500 # since the penalties are not updated during the permit phase
1501 # ------------------------------------------------------------------------------
1502 OpenILS::Utils::Penalty->calculate_penalties($self->editor, $self->patron->id, $self->circ_lib);
1504 my $record = $U->record_to_mvr($self->title) unless $self->is_precat;
1507 if($self->is_renewal) {
1508 # flesh the billing summary for the checked-in circ
1509 $pcirc = $self->editor->retrieve_action_circulation([
1511 {flesh => 2, flesh_fields => {circ => ['billable_transaction'], mbt => ['summary']}}
1516 OpenILS::Event->new('SUCCESS',
1518 copy => $U->unflesh_copy($self->copy),
1519 volume => $self->volume,
1520 circ => $self->circ,
1522 holds_fulfilled => $self->fulfilled_holds,
1523 deposit_billing => $self->deposit_billing,
1524 rental_billing => $self->rental_billing,
1525 parent_circ => $pcirc,
1526 patron => ($self->return_patron) ? $self->patron : undef,
1527 patron_money => $self->editor->retrieve_money_user_summary($self->patron->id)
1533 sub apply_deposit_fee {
1535 my $copy = $self->copy;
1537 ($self->is_deposit and not $self->is_deposit_exempt) or
1538 ($self->is_rental and not $self->is_rental_exempt);
1540 return if $self->is_deposit and $self->skip_deposit_fee;
1541 return if $self->is_rental and $self->skip_rental_fee;
1543 my $bill = Fieldmapper::money::billing->new;
1544 my $amount = $copy->deposit_amount;
1548 if($self->is_deposit) {
1549 $billing_type = OILS_BILLING_TYPE_DEPOSIT;
1551 $self->deposit_billing($bill);
1553 $billing_type = OILS_BILLING_TYPE_RENTAL;
1555 $self->rental_billing($bill);
1558 $bill->xact($self->circ->id);
1559 $bill->amount($amount);
1560 $bill->note(OILS_BILLING_NOTE_SYSTEM);
1561 $bill->billing_type($billing_type);
1562 $bill->btype($btype);
1563 $self->editor->create_money_billing($bill) or $self->bail_on_events($self->editor->event);
1565 $logger->info("circulator: charged $amount on checkout with billing type $billing_type");
1570 my $copy = $self->copy;
1572 my $stat = $copy->status if ref $copy->status;
1573 my $loc = $copy->location if ref $copy->location;
1574 my $circ_lib = $copy->circ_lib if ref $copy->circ_lib;
1576 $copy->status($stat->id) if $stat;
1577 $copy->location($loc->id) if $loc;
1578 $copy->circ_lib($circ_lib->id) if $circ_lib;
1579 $copy->editor($self->editor->requestor->id);
1580 $copy->edit_date('now');
1581 $copy->age_protect($copy->age_protect->id) if ref $copy->age_protect;
1583 return $self->bail_on_events($self->editor->event)
1584 unless $self->editor->update_asset_copy($self->copy);
1586 $copy->status($U->copy_status($copy->status));
1587 $copy->location($loc) if $loc;
1588 $copy->circ_lib($circ_lib) if $circ_lib;
1591 sub update_reservation {
1593 my $reservation = $self->reservation;
1595 my $usr = $reservation->usr;
1596 my $target_rt = $reservation->target_resource_type;
1597 my $target_r = $reservation->target_resource;
1598 my $current_r = $reservation->current_resource;
1600 $reservation->usr($usr->id) if ref $usr;
1601 $reservation->target_resource_type($target_rt->id) if ref $target_rt;
1602 $reservation->target_resource($target_r->id) if ref $target_r;
1603 $reservation->current_resource($current_r->id) if ref $current_r;
1605 return $self->bail_on_events($self->editor->event)
1606 unless $self->editor->update_booking_reservation($self->reservation);
1609 ($reservation, $evt) = $U->fetch_booking_reservation($reservation->id);
1610 $self->reservation($reservation);
1614 sub bail_on_events {
1615 my( $self, @evts ) = @_;
1616 $self->push_events(@evts);
1621 # ------------------------------------------------------------------------------
1622 # When an item is checked out, see if we can fulfill a hold for this patron
1623 # ------------------------------------------------------------------------------
1624 sub handle_checkout_holds {
1626 my $copy = $self->copy;
1627 my $patron = $self->patron;
1629 my $e = $self->editor;
1630 $self->fulfilled_holds([]);
1632 # pre/non-cats can't fulfill a hold
1633 return if $self->is_precat or $self->is_noncat;
1635 my $hold = $e->search_action_hold_request({
1636 current_copy => $copy->id ,
1637 cancel_time => undef,
1638 fulfillment_time => undef,
1640 {expire_time => undef},
1641 {expire_time => {'>' => 'now'}}
1645 if($hold and $hold->usr != $patron->id) {
1646 # reset the hold since the copy is now checked out
1648 $logger->info("circulator: un-targeting hold ".$hold->id.
1649 " because copy ".$copy->id." is getting checked out");
1651 $hold->clear_prev_check_time;
1652 $hold->clear_current_copy;
1653 $hold->clear_capture_time;
1655 return $self->bail_on_event($e->event)
1656 unless $e->update_action_hold_request($hold);
1662 $hold = $self->find_related_user_hold($copy, $patron) or return;
1663 $logger->info("circulator: found related hold to fulfill in checkout");
1666 $logger->debug("circulator: checkout fulfilling hold " . $hold->id);
1668 # if the hold was never officially captured, capture it.
1669 $hold->current_copy($copy->id);
1670 $hold->capture_time('now') unless $hold->capture_time;
1671 $hold->fulfillment_time('now');
1672 $hold->fulfillment_staff($e->requestor->id);
1673 $hold->fulfillment_lib($self->circ_lib);
1675 return $self->bail_on_events($e->event)
1676 unless $e->update_action_hold_request($hold);
1678 $holdcode->delete_hold_copy_maps($e, $hold->id);
1679 return $self->fulfilled_holds([$hold->id]);
1683 # ------------------------------------------------------------------------------
1684 # If the circ.checkout_fill_related_hold setting is turned on and no hold for
1685 # the patron directly targets the checked out item, see if there is another hold
1686 # for the patron that could be fulfilled by the checked out item. Fulfill the
1687 # oldest hold and only fulfill 1 of them.
1689 # For "another hold":
1691 # First, check for one that the copy matches via hold_copy_map, ensuring that
1692 # *any* hold type that this copy could fill may end up filled.
1694 # Then, if circ.checkout_fill_related_hold_exact_match_only is not enabled, look
1695 # for a Title (T) or Volume (V) hold that matches the item. This allows items
1696 # that are non-requestable to count as capturing those hold types.
1697 # ------------------------------------------------------------------------------
1698 sub find_related_user_hold {
1699 my($self, $copy, $patron) = @_;
1700 my $e = $self->editor;
1702 return undef if $self->volume->id == OILS_PRECAT_CALL_NUMBER;
1704 return undef unless $U->ou_ancestor_setting_value(
1705 $self->circ_lib, 'circ.checkout_fills_related_hold', $e);
1707 # find the oldest unfulfilled hold that has not yet hit the holds shelf.
1709 select => {ahr => ['id']},
1721 fulfillment_time => undef,
1722 cancel_time => undef,
1724 {expire_time => undef},
1725 {expire_time => {'>' => 'now'}}
1729 target_copy => $self->copy->id
1732 order_by => {ahr => {request_time => {direction => 'asc'}}},
1736 my $hold_info = $e->json_query($args)->[0];
1737 return $e->retrieve_action_hold_request($hold_info->{id}) if $hold_info;
1738 return undef if $U->ou_ancestor_setting_value(
1739 $self->circ_lib, 'circ.checkout_fills_related_hold_exact_match_only', $e);
1741 # find the oldest unfulfilled hold that has not yet hit the holds shelf.
1743 select => {ahr => ['id']},
1748 fkey => 'current_copy',
1749 type => 'left' # there may be no current_copy
1756 fulfillment_time => undef,
1757 cancel_time => undef,
1759 {expire_time => undef},
1760 {expire_time => {'>' => 'now'}}
1767 target => $self->volume->id
1773 target => $self->title->id
1779 {id => undef}, # left-join copy may be nonexistent
1780 {status => {'!=' => OILS_COPY_STATUS_ON_HOLDS_SHELF}},
1784 order_by => {ahr => {request_time => {direction => 'asc'}}},
1788 $hold_info = $e->json_query($args)->[0];
1789 return $e->retrieve_action_hold_request($hold_info->{id}) if $hold_info;
1794 sub run_checkout_scripts {
1799 my $runner = $self->script_runner;
1808 my $hard_due_date_name;
1810 if(!$self->legacy_script_support) {
1811 $self->run_indb_circ_test();
1812 $duration = $self->circ_matrix_matchpoint->duration_rule;
1813 $recurring = $self->circ_matrix_matchpoint->recurring_fine_rule;
1814 $max_fine = $self->circ_matrix_matchpoint->max_fine_rule;
1815 $hard_due_date = $self->circ_matrix_matchpoint->hard_due_date;
1819 $runner->load($self->circ_duration);
1821 my $result = $runner->run or
1822 throw OpenSRF::EX::ERROR ("Circ Duration Script Died: $@");
1824 $duration_name = $result->{durationRule};
1825 $recurring_name = $result->{recurringFinesRule};
1826 $max_fine_name = $result->{maxFine};
1827 $hard_due_date_name = $result->{hardDueDate};
1830 $duration_name = $duration->name if $duration;
1831 if( $duration_name ne OILS_UNLIMITED_CIRC_DURATION ) {
1834 ($duration, $evt) = $U->fetch_circ_duration_by_name($duration_name);
1835 return $self->bail_on_events($evt) if ($evt && !$nobail);
1837 ($recurring, $evt) = $U->fetch_recurring_fine_by_name($recurring_name);
1838 return $self->bail_on_events($evt) if ($evt && !$nobail);
1840 ($max_fine, $evt) = $U->fetch_max_fine_by_name($max_fine_name);
1841 return $self->bail_on_events($evt) if ($evt && !$nobail);
1843 if($hard_due_date_name) {
1844 ($hard_due_date, $evt) = $U->fetch_hard_due_date_by_name($hard_due_date_name);
1845 return $self->bail_on_events($evt) if ($evt && !$nobail);
1851 # The item circulates with an unlimited duration
1855 $hard_due_date = undef;
1858 $self->duration_rule($duration);
1859 $self->recurring_fines_rule($recurring);
1860 $self->max_fine_rule($max_fine);
1861 $self->hard_due_date($hard_due_date);
1865 sub build_checkout_circ_object {
1868 my $circ = Fieldmapper::action::circulation->new;
1869 my $duration = $self->duration_rule;
1870 my $max = $self->max_fine_rule;
1871 my $recurring = $self->recurring_fines_rule;
1872 my $hard_due_date = $self->hard_due_date;
1873 my $copy = $self->copy;
1874 my $patron = $self->patron;
1875 my $duration_date_ceiling;
1876 my $duration_date_ceiling_force;
1880 my $policy = $self->get_circ_policy($duration, $recurring, $max, $hard_due_date);
1881 $duration_date_ceiling = $policy->{duration_date_ceiling};
1882 $duration_date_ceiling_force = $policy->{duration_date_ceiling_force};
1884 my $dname = $duration->name;
1885 my $mname = $max->name;
1886 my $rname = $recurring->name;
1888 if($hard_due_date) {
1889 $hdname = $hard_due_date->name;
1892 $logger->debug("circulator: building circulation ".
1893 "with duration=$dname, maxfine=$mname, recurring=$rname, hard due date=$hdname");
1895 $circ->duration($policy->{duration});
1896 $circ->recurring_fine($policy->{recurring_fine});
1897 $circ->duration_rule($duration->name);
1898 $circ->recurring_fine_rule($recurring->name);
1899 $circ->max_fine_rule($max->name);
1900 $circ->max_fine($policy->{max_fine});
1901 $circ->fine_interval($recurring->recurrence_interval);
1902 $circ->renewal_remaining($duration->max_renewals);
1903 $circ->grace_period($policy->{grace_period});
1907 $logger->info("circulator: copy found with an unlimited circ duration");
1908 $circ->duration_rule(OILS_UNLIMITED_CIRC_DURATION);
1909 $circ->recurring_fine_rule(OILS_UNLIMITED_CIRC_DURATION);
1910 $circ->max_fine_rule(OILS_UNLIMITED_CIRC_DURATION);
1911 $circ->renewal_remaining(0);
1912 $circ->grace_period(0);
1915 $circ->target_copy( $copy->id );
1916 $circ->usr( $patron->id );
1917 $circ->circ_lib( $self->circ_lib );
1918 $circ->workstation($self->editor->requestor->wsid)
1919 if defined $self->editor->requestor->wsid;
1921 # renewals maintain a link to the parent circulation
1922 $circ->parent_circ($self->parent_circ);
1924 if( $self->is_renewal ) {
1925 $circ->opac_renewal('t') if $self->opac_renewal;
1926 $circ->phone_renewal('t') if $self->phone_renewal;
1927 $circ->desk_renewal('t') if $self->desk_renewal;
1928 $circ->renewal_remaining($self->renewal_remaining);
1929 $circ->circ_staff($self->editor->requestor->id);
1933 # if the user provided an overiding checkout time,
1934 # (e.g. the checkout really happened several hours ago), then
1935 # we apply that here. Does this need a perm??
1936 $circ->xact_start(cleanse_ISO8601($self->checkout_time))
1937 if $self->checkout_time;
1939 # if a patron is renewing, 'requestor' will be the patron
1940 $circ->circ_staff($self->editor->requestor->id);
1941 $circ->due_date( $self->create_due_date($circ->duration, $duration_date_ceiling, $duration_date_ceiling_force) ) if $circ->duration;
1946 sub do_reservation_pickup {
1949 $self->log_me("do_reservation_pickup()");
1951 $self->reservation->pickup_time('now');
1954 $self->reservation->current_resource &&
1955 $U->is_true($self->reservation->target_resource_type->catalog_item)
1957 # We used to try to set $self->copy and $self->patron here,
1958 # but that should already be done.
1960 $self->run_checkout_scripts(1);
1962 my $duration = $self->duration_rule;
1963 my $max = $self->max_fine_rule;
1964 my $recurring = $self->recurring_fines_rule;
1966 if ($duration && $max && $recurring) {
1967 my $policy = $self->get_circ_policy($duration, $recurring, $max);
1969 my $dname = $duration->name;
1970 my $mname = $max->name;
1971 my $rname = $recurring->name;
1973 $logger->debug("circulator: updating reservation ".
1974 "with duration=$dname, maxfine=$mname, recurring=$rname");
1976 $self->reservation->fine_amount($policy->{recurring_fine});
1977 $self->reservation->max_fine($policy->{max_fine});
1978 $self->reservation->fine_interval($recurring->recurrence_interval);
1981 $self->copy->status(OILS_COPY_STATUS_CHECKED_OUT);
1982 $self->update_copy();
1985 $self->reservation->fine_amount(
1986 $self->reservation->target_resource_type->fine_amount
1988 $self->reservation->max_fine(
1989 $self->reservation->target_resource_type->max_fine
1991 $self->reservation->fine_interval(
1992 $self->reservation->target_resource_type->fine_interval
1996 $self->update_reservation();
1999 sub do_reservation_return {
2001 my $request = shift;
2003 $self->log_me("do_reservation_return()");
2005 if (not ref $self->reservation) {
2006 my ($reservation, $evt) =
2007 $U->fetch_booking_reservation($self->reservation);
2008 return $self->bail_on_events($evt) if $evt;
2009 $self->reservation($reservation);
2012 $self->generate_fines(1);
2013 $self->reservation->return_time('now');
2014 $self->update_reservation();
2015 $self->reshelve_copy if $self->copy;
2017 if ( $self->reservation->current_resource && $self->reservation->current_resource->catalog_item ) {
2018 $self->copy( $self->reservation->current_resource->catalog_item );
2022 sub booking_adjusted_due_date {
2024 my $circ = $self->circ;
2025 my $copy = $self->copy;
2027 return undef unless $self->use_booking;
2031 if( $self->due_date ) {
2033 return $self->bail_on_events($self->editor->event)
2034 unless $self->editor->allowed('CIRC_OVERRIDE_DUE_DATE', $self->circ_lib);
2036 $circ->due_date(cleanse_ISO8601($self->due_date));
2040 return unless $copy and $circ->due_date;
2043 my $booking_items = $self->editor->search_booking_resource( { barcode => $copy->barcode } );
2044 if (@$booking_items) {
2045 my $booking_item = $booking_items->[0];
2046 my $resource_type = $self->editor->retrieve_booking_resource_type( $booking_item->type );
2048 my $stop_circ_setting = $U->ou_ancestor_setting_value( $self->circ_lib, 'circ.booking_reservation.stop_circ', $self->editor );
2049 my $shorten_circ_setting = $resource_type->elbow_room ||
2050 $U->ou_ancestor_setting_value( $self->circ_lib, 'circ.booking_reservation.default_elbow_room', $self->editor ) ||
2053 my $booking_ses = OpenSRF::AppSession->create( 'open-ils.booking' );
2054 my $bookings = $booking_ses->request(
2055 'open-ils.booking.reservations.filtered_id_list', $self->editor->authtoken,
2056 { resource => $booking_item->id, search_start => 'now', search_end => $circ->due_date, fields => { cancel_time => undef }}
2058 $booking_ses->disconnect;
2060 my $dt_parser = DateTime::Format::ISO8601->new;
2061 my $due_date = $dt_parser->parse_datetime( cleanse_ISO8601($circ->due_date) );
2063 for my $bid (@$bookings) {
2065 my $booking = $self->editor->retrieve_booking_reservation( $bid );
2067 my $booking_start = $dt_parser->parse_datetime( cleanse_ISO8601($booking->start_time) );
2068 my $booking_end = $dt_parser->parse_datetime( cleanse_ISO8601($booking->end_time) );
2070 return $self->bail_on_events( OpenILS::Event->new('COPY_RESERVED') )
2071 if ($booking_start < DateTime->now);
2074 if ($U->is_true($stop_circ_setting)) {
2075 $self->bail_on_events( OpenILS::Event->new('COPY_RESERVED') );
2077 $due_date = $booking_start->subtract( seconds => interval_to_seconds($shorten_circ_setting) );
2078 $self->bail_on_events( OpenILS::Event->new('COPY_RESERVED') ) if ($due_date < DateTime->now);
2081 # We set the circ duration here only to affect the logic that will
2082 # later (in a DB trigger) mangle the time part of the due date to
2083 # 11:59pm. Having any circ duration that is not a whole number of
2084 # days is enough to prevent the "correction."
2085 my $new_circ_duration = $due_date->epoch - time;
2086 $new_circ_duration++ if $new_circ_duration % 86400 == 0;
2087 $circ->duration("$new_circ_duration seconds");
2089 $circ->due_date(cleanse_ISO8601($due_date->strftime('%FT%T%z')));
2093 return $self->bail_on_events($self->editor->event)
2094 unless $self->editor->allowed('CIRC_OVERRIDE_DUE_DATE', $self->circ_lib);
2100 sub apply_modified_due_date {
2102 my $shift_earlier = shift;
2103 my $circ = $self->circ;
2104 my $copy = $self->copy;
2106 if( $self->due_date ) {
2108 return $self->bail_on_events($self->editor->event)
2109 unless $self->editor->allowed('CIRC_OVERRIDE_DUE_DATE', $self->circ_lib);
2111 $circ->due_date(cleanse_ISO8601($self->due_date));
2115 # if the due_date lands on a day when the location is closed
2116 return unless $copy and $circ->due_date;
2118 #my $org = (ref $copy->circ_lib) ? $copy->circ_lib->id : $copy->circ_lib;
2120 # due-date overlap should be determined by the location the item
2121 # is checked out from, not the owning or circ lib of the item
2122 my $org = $self->circ_lib;
2124 $logger->info("circulator: circ searching for closed date overlap on lib $org".
2125 " with an item due date of ".$circ->due_date );
2127 my $dateinfo = $U->storagereq(
2128 'open-ils.storage.actor.org_unit.closed_date.overlap',
2129 $org, $circ->due_date );
2132 $logger->info("circulator: $dateinfo : circ due data / close date overlap found : due_date=".
2133 $circ->due_date." start=". $dateinfo->{start}.", end=".$dateinfo->{end});
2135 # XXX make the behavior more dynamic
2136 # for now, we just push the due date to after the close date
2137 if ($shift_earlier) {
2138 $circ->due_date($dateinfo->{start});
2140 $circ->due_date($dateinfo->{end});
2148 sub create_due_date {
2149 my( $self, $duration, $date_ceiling, $force_date ) = @_;
2151 # if there is a raw time component (e.g. from postgres),
2152 # turn it into an interval that interval_to_seconds can parse
2153 $duration =~ s/(\d{2}):(\d{2}):(\d{2})/$1 h $2 m $3 s/o;
2155 # for now, use the server timezone. TODO: use workstation org timezone
2156 my $due_date = DateTime->now(time_zone => 'local');
2158 # add the circ duration
2159 $due_date->add(seconds => OpenSRF::Utils->interval_to_seconds($duration));
2162 my $cdate = DateTime::Format::ISO8601->new->parse_datetime(cleanse_ISO8601($date_ceiling));
2163 if ($cdate > DateTime->now and ($cdate < $due_date or $U->is_true( $force_date ))) {
2164 $logger->info("circulator: overriding due date with date ceiling: $date_ceiling");
2169 # return ISO8601 time with timezone
2170 return $due_date->strftime('%FT%T%z');
2175 sub make_precat_copy {
2177 my $copy = $self->copy;
2180 $logger->debug("circulator: Pre-cat copy already exists in checkout: ID=" . $copy->id);
2182 $copy->editor($self->editor->requestor->id);
2183 $copy->edit_date('now');
2184 $copy->dummy_title($self->dummy_title || $copy->dummy_title || '');
2185 $copy->dummy_isbn($self->dummy_isbn || $copy->dummy_isbn || '');
2186 $copy->dummy_author($self->dummy_author || $copy->dummy_author || '');
2187 $copy->circ_modifier($self->circ_modifier || $copy->circ_modifier);
2188 $self->update_copy();
2192 $logger->info("circulator: Creating a new precataloged ".
2193 "copy in checkout with barcode " . $self->copy_barcode);
2195 $copy = Fieldmapper::asset::copy->new;
2196 $copy->circ_lib($self->circ_lib);
2197 $copy->creator($self->editor->requestor->id);
2198 $copy->editor($self->editor->requestor->id);
2199 $copy->barcode($self->copy_barcode);
2200 $copy->call_number(OILS_PRECAT_CALL_NUMBER);
2201 $copy->loan_duration(OILS_PRECAT_COPY_LOAN_DURATION);
2202 $copy->fine_level(OILS_PRECAT_COPY_FINE_LEVEL);
2204 $copy->dummy_title($self->dummy_title || "");
2205 $copy->dummy_author($self->dummy_author || "");
2206 $copy->dummy_isbn($self->dummy_isbn || "");
2207 $copy->circ_modifier($self->circ_modifier);
2210 # See if we need to override the circ_lib for the copy with a configured circ_lib
2211 # Setting is shortname of the org unit
2212 my $precat_circ_lib = $U->ou_ancestor_setting_value(
2213 $self->circ_lib, 'circ.pre_cat_copy_circ_lib', $self->editor);
2215 if($precat_circ_lib) {
2216 my $org = $self->editor->search_actor_org_unit({shortname => $precat_circ_lib})->[0];
2219 $self->bail_on_events($self->editor->event);
2223 $copy->circ_lib($org->id);
2227 unless( $self->copy($self->editor->create_asset_copy($copy)) ) {
2229 $self->push_events($self->editor->event);
2233 # this is a little bit of a hack, but we need to
2234 # get the copy into the script runner
2235 $self->script_runner->insert("environment.copy", $copy, 1) if $self->script_runner;
2239 sub checkout_noncat {
2245 my $lib = $self->noncat_circ_lib || $self->circ_lib;
2246 my $count = $self->noncat_count || 1;
2247 my $cotime = cleanse_ISO8601($self->checkout_time) || "";
2249 $logger->info("circulator: circ creating $count noncat circs with checkout time $cotime");
2253 ( $circ, $evt ) = OpenILS::Application::Circ::NonCat::create_non_cat_circ(
2254 $self->editor->requestor->id,
2262 $self->push_events($evt);
2270 # If a copy goes into transit and is then checked in before the transit checkin
2271 # interval has expired, push an event onto the overridable events list.
2272 sub check_transit_checkin_interval {
2275 # only concerned with in-transit items
2276 return unless $U->copy_status($self->copy->status)->id == OILS_COPY_STATUS_IN_TRANSIT;
2278 # no interval, no problem
2279 my $interval = $U->ou_ancestor_setting_value($self->circ_lib, 'circ.transit.min_checkin_interval');
2280 return unless $interval;
2282 # capture the transit so we don't have to fetch it again later during checkin
2284 $self->editor->search_action_transit_copy(
2285 {target_copy => $self->copy->id, dest_recv_time => undef}
2289 my $seconds = OpenSRF::Utils->interval_to_seconds($interval);
2290 my $t_start = DateTime::Format::ISO8601->new->parse_datetime(cleanse_ISO8601($self->transit->source_send_time));
2291 my $horizon = $t_start->add(seconds => $seconds);
2293 # See if we are still within the transit checkin forbidden range
2294 $self->push_events(OpenILS::Event->new('TRANSIT_CHECKIN_INTERVAL_BLOCK'))
2295 if $horizon > DateTime->now;
2298 # Retarget local holds at checkin
2299 sub checkin_retarget {
2301 return unless $self->retarget_mode =~ m/retarget/; # Retargeting?
2302 return unless $self->is_checkin; # Renewals need not be checked
2303 return if $self->capture eq 'nocapture'; # Not capturing holds anyway? Move on.
2304 return if $self->is_precat; # No holds for precats
2305 return unless $self->circ_lib == $self->copy->circ_lib; # Item isn't "home"? Don't check.
2306 return unless $self->copy->holdable; # Not holdable, shouldn't capture holds.
2307 # Specifically target items that are likely new (by status ID)
2308 unless ($self->retarget_mode =~ m/\.all/) {
2309 my $status = $U->copy_status($self->copy->status)->id;
2310 return unless $status == OILS_COPY_STATUS_IN_PROCESS;
2313 # Fetch holds for the bib
2314 my ($result) = $holdcode->method_lookup('open-ils.circ.holds.retrieve_all_from_title')->run(
2315 $self->editor->authtoken,
2318 capture_time => undef, # No touching captured holds
2319 frozen => 'f', # Don't bother with frozen holds
2320 pickup_lib => $self->circ_lib # Only holds actually here
2323 # Error? Skip the step.
2324 return if exists $result->{"ilsevent"};
2328 foreach my $holdlist (keys %{$result}) {
2329 push @$holds, @{$result->{$holdlist}};
2332 return if scalar(@$holds) == 0; # No holds, no retargeting
2334 # Loop over holds in request-ish order
2335 # Stage 1: Get them into request-ish order
2336 # Also grab type and target for skipping low hanging ones
2337 $result = $self->editor->json_query({
2338 "select" => { "ahr" => ["id", "hold_type", "target"] },
2339 "from" => { "ahr" => { "au" => { "fkey" => "usr", "join" => "pgt"} } },
2340 "where" => { "id" => $holds },
2342 { "class" => "pgt", "field" => "hold_priority"},
2343 { "class" => "ahr", "field" => "cut_in_line", "direction" => "desc", "transform" => "coalesce", "params" => ['f']},
2344 { "class" => "ahr", "field" => "selection_depth", "direction" => "desc"},
2345 { "class" => "ahr", "field" => "request_time"}
2350 if (ref $result eq "ARRAY" and scalar @$result) {
2351 foreach (@{$result}) {
2352 # Copy level, but not this copy?
2353 next if ($_->{hold_type} eq 'C' or $_->{hold_type} eq 'R' or $_->{hold_type} eq 'F'
2354 and $_->{target} != $self->copy->id);
2355 # Volume level, but not this volume?
2356 next if ($_->{hold_type} eq 'V' and $_->{target} != $self->volume->id);
2357 # So much for easy stuff, attempt a retarget!
2358 my $tresult = $U->storagereq('open-ils.storage.action.hold_request.copy_targeter', undef, $_->{id}, $self->copy->id);
2359 if(ref $tresult eq "ARRAY" and scalar @$tresult) {
2360 last if(exists $tresult->[0]->{found_copy} and $tresult->[0]->{found_copy});
2368 $self->log_me("do_checkin()");
2370 return $self->bail_on_events(
2371 OpenILS::Event->new('ASSET_COPY_NOT_FOUND'))
2374 $self->check_transit_checkin_interval;
2375 $self->checkin_retarget;
2377 # the renew code and mk_env should have already found our circulation object
2378 unless( $self->circ ) {
2380 my $circs = $self->editor->search_action_circulation(
2381 { target_copy => $self->copy->id, checkin_time => undef });
2383 $self->circ($$circs[0]);
2385 # for now, just warn if there are multiple open circs on a copy
2386 $logger->warn("circulator: we have ".scalar(@$circs).
2387 " open circs for copy " .$self->copy->id."!!") if @$circs > 1;
2390 # run the fine generator against this circ, if this circ is there
2391 $self->generate_fines_start if $self->circ;
2393 if( $self->checkin_check_holds_shelf() ) {
2394 $self->bail_on_events(OpenILS::Event->new('NO_CHANGE'));
2395 $self->hold($U->fetch_open_hold_by_copy($self->copy->id));
2396 $self->checkin_flesh_events;
2400 unless( $self->is_renewal ) {
2401 return $self->bail_on_events($self->editor->event)
2402 unless $self->editor->allowed('COPY_CHECKIN');
2405 $self->push_events($self->check_copy_alert());
2406 $self->push_events($self->check_checkin_copy_status());
2408 # if the circ is marked as 'claims returned', add the event to the list
2409 $self->push_events(OpenILS::Event->new('CIRC_CLAIMS_RETURNED'))
2410 if ($self->circ and $self->circ->stop_fines
2411 and $self->circ->stop_fines eq OILS_STOP_FINES_CLAIMSRETURNED);
2413 $self->check_circ_deposit();
2415 # handle the overridable events
2416 $self->override_events unless $self->is_renewal;
2417 return if $self->bail_out;
2419 if( $self->copy and !$self->transit ) {
2421 $self->editor->search_action_transit_copy(
2422 { target_copy => $self->copy->id, dest_recv_time => undef }
2428 $self->generate_fines_finish;
2429 $self->checkin_handle_circ;
2430 return if $self->bail_out;
2431 $self->checkin_changed(1);
2433 } elsif( $self->transit ) {
2434 my $hold_transit = $self->process_received_transit;
2435 $self->checkin_changed(1);
2437 if( $self->bail_out ) {
2438 $self->checkin_flesh_events;
2442 if( my $e = $self->check_checkin_copy_status() ) {
2443 # If the original copy status is special, alert the caller
2444 my $ev = $self->events;
2445 $self->events([$e]);
2446 $self->override_events;
2447 return if $self->bail_out;
2451 if( $hold_transit or
2452 $U->copy_status($self->copy->status)->id
2453 == OILS_COPY_STATUS_ON_HOLDS_SHELF ) {
2456 if( $hold_transit ) {
2457 $hold = $self->editor->retrieve_action_hold_request($hold_transit->hold);
2459 ($hold) = $U->fetch_open_hold_by_copy($self->copy->id);
2464 if( $hold and $hold->cancel_time ) { # this transited hold was cancelled mid-transit
2466 $logger->info("circulator: we received a transit on a cancelled hold " . $hold->id);
2467 $self->reshelve_copy(1);
2468 $self->cancelled_hold_transit(1);
2469 $self->notify_hold(0); # don't notify for cancelled holds
2470 return if $self->bail_out;
2474 # hold transited to correct location
2475 $self->checkin_flesh_events;
2480 } elsif( $U->copy_status($self->copy->status)->id == OILS_COPY_STATUS_IN_TRANSIT ) {
2482 $logger->warn("circulator: we have a copy ".$self->copy->barcode.
2483 " that is in-transit, but there is no transit.. repairing");
2484 $self->reshelve_copy(1);
2485 return if $self->bail_out;
2488 if( $self->is_renewal ) {
2489 $self->finish_fines_and_voiding;
2490 return if $self->bail_out;
2491 $self->push_events(OpenILS::Event->new('SUCCESS'));
2495 # ------------------------------------------------------------------------------
2496 # Circulations and transits are now closed where necessary. Now go on to see if
2497 # this copy can fulfill a hold or needs to be routed to a different location
2498 # ------------------------------------------------------------------------------
2500 my $needed_for_something = 0; # formerly "needed_for_hold"
2502 if(!$self->noop) { # /not/ a no-op checkin, capture for hold or put item into transit
2504 if (!$self->remote_hold) {
2505 if ($self->use_booking) {
2506 my $potential_hold = $self->hold_capture_is_possible;
2507 my $potential_reservation = $self->reservation_capture_is_possible;
2509 if ($potential_hold and $potential_reservation) {
2510 $logger->info("circulator: item could fulfill either hold or reservation");
2511 $self->push_events(new OpenILS::Event(
2512 "HOLD_RESERVATION_CONFLICT",
2513 "hold" => $potential_hold,
2514 "reservation" => $potential_reservation
2516 return if $self->bail_out;
2517 } elsif ($potential_hold) {
2518 $needed_for_something =
2519 $self->attempt_checkin_hold_capture;
2520 } elsif ($potential_reservation) {
2521 $needed_for_something =
2522 $self->attempt_checkin_reservation_capture;
2525 $needed_for_something = $self->attempt_checkin_hold_capture;
2528 return if $self->bail_out;
2530 unless($needed_for_something) {
2531 my $circ_lib = (ref $self->copy->circ_lib) ?
2532 $self->copy->circ_lib->id : $self->copy->circ_lib;
2534 if( $self->remote_hold ) {
2535 $circ_lib = $self->remote_hold->pickup_lib;
2536 $logger->warn("circulator: Copy ".$self->copy->barcode.
2537 " is on a remote hold's shelf, sending to $circ_lib");
2540 $logger->debug("circulator: circlib=$circ_lib, workstation=".$self->circ_lib);
2542 if( $circ_lib == $self->circ_lib) {
2543 # copy is where it needs to be, either for hold or reshelving
2545 $self->checkin_handle_precat();
2546 return if $self->bail_out;
2549 # copy needs to transit "home", or stick here if it's a floating copy
2551 if ($U->is_true( $self->copy->floating ) && !$self->remote_hold) { # copy is floating, stick here
2552 $self->checkin_changed(1);
2553 $self->copy->circ_lib( $self->circ_lib );
2556 my $bc = $self->copy->barcode;
2557 $logger->info("circulator: copy $bc at the wrong location, sending to $circ_lib");
2558 $self->checkin_build_copy_transit($circ_lib);
2559 return if $self->bail_out;
2560 $self->push_events(OpenILS::Event->new('ROUTE_ITEM', org => $circ_lib));
2564 } else { # no-op checkin
2565 if ($U->is_true( $self->copy->floating )) { # XXX floating items still stick where they are even with no-op checkin?
2566 $self->checkin_changed(1);
2567 $self->copy->circ_lib( $self->circ_lib );
2572 if($self->claims_never_checked_out and
2573 $U->ou_ancestor_setting_value($self->circ->circ_lib, 'circ.claim_never_checked_out.mark_missing')) {
2575 # the item was not supposed to be checked out to the user and should now be marked as missing
2576 $self->copy->status(OILS_COPY_STATUS_MISSING);
2580 $self->reshelve_copy unless $needed_for_something;
2583 return if $self->bail_out;
2585 unless($self->checkin_changed) {
2587 $self->push_events(OpenILS::Event->new('NO_CHANGE'));
2588 my $stat = $U->copy_status($self->copy->status)->id;
2590 $self->hold($U->fetch_open_hold_by_copy($self->copy->id))
2591 if( $stat == OILS_COPY_STATUS_ON_HOLDS_SHELF );
2592 $self->bail_out(1); # no need to commit anything
2596 $self->push_events(OpenILS::Event->new('SUCCESS'))
2597 unless @{$self->events};
2600 $self->finish_fines_and_voiding;
2602 OpenILS::Utils::Penalty->calculate_penalties(
2603 $self->editor, $self->patron->id, $self->circ_lib) if $self->patron;
2605 $self->checkin_flesh_events;
2609 sub finish_fines_and_voiding {
2611 return unless $self->circ;
2613 # gather any updates to the circ after fine generation, if there was a circ
2614 $self->generate_fines_finish;
2616 return unless $self->backdate or $self->void_overdues;
2618 # void overdues after fine generation to prevent concurrent DB access to overdue billings
2619 my $note = 'System: Amnesty Checkin' if $self->void_overdues;
2621 my $evt = OpenILS::Application::Circ::CircCommon->void_overdues(
2622 $self->editor, $self->circ, $self->backdate, $note);
2624 return $self->bail_on_events($evt) if $evt;
2626 # make sure the circ isn't closed if we just voided some fines
2627 $evt = OpenILS::Application::Circ::CircCommon->reopen_xact($self->editor, $self->circ->id);
2628 return $self->bail_on_events($evt) if $evt;
2634 # if a deposit was payed for this item, push the event
2635 sub check_circ_deposit {
2637 return unless $self->circ;
2638 my $deposit = $self->editor->search_money_billing(
2640 xact => $self->circ->id,
2642 }, {idlist => 1})->[0];
2644 $self->push_events(OpenILS::Event->new(
2645 'ITEM_DEPOSIT_PAID', payload => $deposit)) if $deposit;
2650 my $force = $self->force || shift;
2651 my $copy = $self->copy;
2653 my $stat = $U->copy_status($copy->status)->id;
2656 $stat != OILS_COPY_STATUS_ON_HOLDS_SHELF and
2657 $stat != OILS_COPY_STATUS_CATALOGING and
2658 $stat != OILS_COPY_STATUS_IN_TRANSIT and
2659 $stat != OILS_COPY_STATUS_RESHELVING )) {
2661 $copy->status( OILS_COPY_STATUS_RESHELVING );
2663 $self->checkin_changed(1);
2668 # Returns true if the item is at the current location
2669 # because it was transited there for a hold and the
2670 # hold has not been fulfilled
2671 sub checkin_check_holds_shelf {
2673 return 0 unless $self->copy;
2676 $U->copy_status($self->copy->status)->id ==
2677 OILS_COPY_STATUS_ON_HOLDS_SHELF;
2679 # Attempt to clear shelf expired holds for this copy
2680 $holdcode->method_lookup('open-ils.circ.hold.clear_shelf.process')->run($self->editor->authtoken, $self->circ_lib, $self->copy->id)
2681 if($self->clear_expired);
2683 # find the hold that put us on the holds shelf
2684 my $holds = $self->editor->search_action_hold_request(
2686 current_copy => $self->copy->id,
2687 capture_time => { '!=' => undef },
2688 fulfillment_time => undef,
2689 cancel_time => undef,
2694 $logger->warn("circulator: copy is on-holds-shelf, but there is no hold - reshelving");
2695 $self->reshelve_copy(1);
2699 my $hold = $$holds[0];
2701 $logger->info("circulator: we found a captured, un-fulfilled hold [".
2702 $hold->id. "] for copy ".$self->copy->barcode);
2704 if( $hold->pickup_lib == $self->circ_lib ) {
2705 $logger->info("circulator: hold is for here .. we're done: ".$self->copy->barcode);
2709 $logger->info("circulator: hold is not for here..");
2710 $self->remote_hold($hold);
2715 sub checkin_handle_precat {
2717 my $copy = $self->copy;
2719 if( $self->is_precat and ($copy->status != OILS_COPY_STATUS_CATALOGING) ) {
2720 $copy->status(OILS_COPY_STATUS_CATALOGING);
2721 $self->update_copy();
2722 $self->checkin_changed(1);
2723 $self->push_events(OpenILS::Event->new('ITEM_NOT_CATALOGED'));
2728 sub checkin_build_copy_transit {
2731 my $copy = $self->copy;
2732 my $transit = Fieldmapper::action::transit_copy->new;
2734 #$dest ||= (ref($copy->circ_lib)) ? $copy->circ_lib->id : $copy->circ_lib;
2735 $logger->info("circulator: transiting copy to $dest");
2737 $transit->source($self->circ_lib);
2738 $transit->dest($dest);
2739 $transit->target_copy($copy->id);
2740 $transit->source_send_time('now');
2741 $transit->copy_status( $U->copy_status($copy->status)->id );
2743 $logger->debug("circulator: setting copy status on transit: ".$transit->copy_status);
2745 return $self->bail_on_events($self->editor->event)
2746 unless $self->editor->create_action_transit_copy($transit);
2748 $copy->status(OILS_COPY_STATUS_IN_TRANSIT);
2750 $self->checkin_changed(1);
2754 sub hold_capture_is_possible {
2756 my $copy = $self->copy;
2758 # we've been explicitly told not to capture any holds
2759 return 0 if $self->capture eq 'nocapture';
2761 # See if this copy can fulfill any holds
2762 my $hold = $holdcode->find_nearest_permitted_hold(
2763 $self->editor, $copy, $self->editor->requestor, 1 # check_only
2765 return undef if ref $hold eq "HASH" and
2766 $hold->{"textcode"} eq "ACTION_HOLD_REQUEST_NOT_FOUND";
2770 sub reservation_capture_is_possible {
2772 my $copy = $self->copy;
2774 # we've been explicitly told not to capture any holds
2775 return 0 if $self->capture eq 'nocapture';
2777 my $booking_ses = OpenSRF::AppSession->connect("open-ils.booking");
2778 my $resv = $booking_ses->request(
2779 "open-ils.booking.reservations.could_capture",
2780 $self->editor->authtoken, $copy->barcode
2782 $booking_ses->disconnect;
2783 if (ref($resv) eq "HASH" and exists $resv->{"textcode"}) {
2784 $self->push_events($resv);
2790 # returns true if the item was used (or may potentially be used
2791 # in subsequent calls) to capture a hold.
2792 sub attempt_checkin_hold_capture {
2794 my $copy = $self->copy;
2796 # we've been explicitly told not to capture any holds
2797 return 0 if $self->capture eq 'nocapture';
2799 # See if this copy can fulfill any holds
2800 my ($hold, undef, $retarget) = $holdcode->find_nearest_permitted_hold(
2801 $self->editor, $copy, $self->editor->requestor );
2804 $logger->debug("circulator: no potential permitted".
2805 "holds found for copy ".$copy->barcode);
2809 if($self->capture ne 'capture') {
2810 # see if this item is in a hold-capture-delay location
2811 my $location = $self->editor->retrieve_asset_copy_location($self->copy->location);
2812 if($U->is_true($location->hold_verify)) {
2813 $self->bail_on_events(
2814 OpenILS::Event->new('HOLD_CAPTURE_DELAYED', copy_location => $location));
2819 $self->retarget($retarget);
2821 $logger->info("circulator: found permitted hold ".$hold->id." for copy, capturing...");
2823 $hold->current_copy($copy->id);
2824 $hold->capture_time('now');
2825 $self->put_hold_on_shelf($hold)
2826 if $hold->pickup_lib == $self->circ_lib;
2828 # prevent DB errors caused by fetching
2829 # holds from storage, and updating through cstore
2830 $hold->clear_fulfillment_time;
2831 $hold->clear_fulfillment_staff;
2832 $hold->clear_fulfillment_lib;
2833 $hold->clear_expire_time;
2834 $hold->clear_cancel_time;
2835 $hold->clear_prev_check_time unless $hold->prev_check_time;
2837 $self->bail_on_events($self->editor->event)
2838 unless $self->editor->update_action_hold_request($hold);
2840 $self->checkin_changed(1);
2842 return 0 if $self->bail_out;
2844 if( $hold->pickup_lib == $self->circ_lib ) {
2846 # This hold was captured in the correct location
2847 $copy->status(OILS_COPY_STATUS_ON_HOLDS_SHELF);
2848 $self->push_events(OpenILS::Event->new('SUCCESS'));
2850 #$self->do_hold_notify($hold->id);
2851 $self->notify_hold($hold->id);
2855 # Hold needs to be picked up elsewhere. Build a hold
2856 # transit and route the item.
2857 $self->checkin_build_hold_transit();
2858 $copy->status(OILS_COPY_STATUS_IN_TRANSIT);
2859 return 0 if $self->bail_out;
2860 $self->push_events(OpenILS::Event->new('ROUTE_ITEM', org => $hold->pickup_lib));
2863 # make sure we save the copy status
2868 sub attempt_checkin_reservation_capture {
2870 my $copy = $self->copy;
2872 # we've been explicitly told not to capture any holds
2873 return 0 if $self->capture eq 'nocapture';
2875 my $booking_ses = OpenSRF::AppSession->connect("open-ils.booking");
2876 my $evt = $booking_ses->request(
2877 "open-ils.booking.resources.capture_for_reservation",
2878 $self->editor->authtoken,
2880 1 # don't update copy - we probably have it locked
2882 $booking_ses->disconnect;
2884 if (ref($evt) ne "HASH" or not exists $evt->{"textcode"}) {
2886 "open-ils.booking.resources.capture_for_reservation " .
2887 "didn't return an event!"
2891 $evt->{"textcode"} eq "RESERVATION_NOT_FOUND" and
2892 $evt->{"payload"}->{"fail_cause"} eq "not-transferable"
2894 # not-transferable is an error event we'll pass on the user
2895 $logger->warn("reservation capture attempted against non-transferable item");
2896 $self->push_events($evt);
2898 } elsif ($evt->{"textcode"} eq "SUCCESS") {
2899 # Re-retrieve copy as reservation capture may have changed
2900 # its status and whatnot.
2902 "circulator: booking capture win on copy " . $self->copy->id
2904 if (my $new_copy_status = $evt->{"payload"}->{"new_copy_status"}) {
2906 "circulator: changing copy " . $self->copy->id .
2907 "'s status from " . $self->copy->status . " to " .
2910 $self->copy->status($new_copy_status);
2913 $self->reservation($evt->{"payload"}->{"reservation"});
2915 if (exists $evt->{"payload"}->{"transit"}) {
2919 "org" => $evt->{"payload"}->{"transit"}->dest
2923 $self->checkin_changed(1);
2927 # other results are treated as "nothing to capture"
2931 sub do_hold_notify {
2932 my( $self, $holdid ) = @_;
2934 my $e = new_editor(xact => 1);
2935 my $hold = $e->retrieve_action_hold_request($holdid) or return $e->die_event;
2937 my $ses = OpenSRF::AppSession->create('open-ils.trigger');
2938 $ses->request('open-ils.trigger.event.autocreate', 'hold.available', $hold, $hold->pickup_lib);
2940 $logger->info("circulator: running delayed hold notify process");
2942 # my $notifier = OpenILS::Application::Circ::HoldNotify->new(
2943 # hold_id => $holdid, editor => new_editor(requestor=>$self->editor->requestor));
2945 my $notifier = OpenILS::Application::Circ::HoldNotify->new(
2946 hold_id => $holdid, requestor => $self->editor->requestor);
2948 $logger->debug("circulator: built hold notifier");
2950 if(!$notifier->event) {
2952 $logger->info("circulator: attempt at sending hold notification for hold $holdid");
2954 my $stat = $notifier->send_email_notify;
2955 if( $stat == '1' ) {
2956 $logger->info("circulator: hold notify succeeded for hold $holdid");
2960 $logger->debug("circulator: * hold notify cancelled or failed for hold $holdid");
2963 $logger->info("circulator: Not sending hold notification since the patron has no email address");
2967 sub retarget_holds {
2969 $logger->info("circulator: retargeting holds @{$self->retarget} after opportunistic capture");
2970 my $ses = OpenSRF::AppSession->create('open-ils.storage');
2971 $ses->request('open-ils.storage.action.hold_request.copy_targeter', undef, $self->retarget);
2972 # no reason to wait for the return value
2976 sub checkin_build_hold_transit {
2979 my $copy = $self->copy;
2980 my $hold = $self->hold;
2981 my $trans = Fieldmapper::action::hold_transit_copy->new;
2983 $logger->debug("circulator: building hold transit for ".$copy->barcode);
2985 $trans->hold($hold->id);
2986 $trans->source($self->circ_lib);
2987 $trans->dest($hold->pickup_lib);
2988 $trans->source_send_time("now");
2989 $trans->target_copy($copy->id);
2991 # when the copy gets to its destination, it will recover
2992 # this status - put it onto the holds shelf
2993 $trans->copy_status(OILS_COPY_STATUS_ON_HOLDS_SHELF);
2995 return $self->bail_on_events($self->editor->event)
2996 unless $self->editor->create_action_hold_transit_copy($trans);
3001 sub process_received_transit {
3003 my $copy = $self->copy;
3004 my $copyid = $self->copy->id;
3006 my $status_name = $U->copy_status($copy->status)->name;
3007 $logger->debug("circulator: attempting transit receive on ".
3008 "copy $copyid. Copy status is $status_name");
3010 my $transit = $self->transit;
3012 if( $transit->dest != $self->circ_lib ) {
3013 # - this item is in-transit to a different location
3015 my $tid = $transit->id;
3016 my $loc = $self->circ_lib;
3017 my $dest = $transit->dest;
3019 $logger->info("circulator: Fowarding transit on copy which is destined ".
3020 "for a different location. transit=$tid, copy=$copyid, current ".
3021 "location=$loc, destination location=$dest");
3023 my $evt = OpenILS::Event->new('ROUTE_ITEM', org => $dest, payload => {});
3025 # grab the associated hold object if available
3026 my $ht = $self->editor->retrieve_action_hold_transit_copy($tid);
3027 $self->hold($self->editor->retrieve_action_hold_request($ht->hold)) if $ht;
3029 return $self->bail_on_events($evt);
3032 # The transit is received, set the receive time
3033 $transit->dest_recv_time('now');
3034 $self->bail_on_events($self->editor->event)
3035 unless $self->editor->update_action_transit_copy($transit);
3037 my $hold_transit = $self->editor->retrieve_action_hold_transit_copy($transit->id);
3039 $logger->info("circulator: Recovering original copy status in transit: ".$transit->copy_status);
3040 $copy->status( $transit->copy_status );
3041 $self->update_copy();
3042 return if $self->bail_out;
3046 my $hold = $self->editor->retrieve_action_hold_request($hold_transit->hold);
3048 # hold has arrived at destination, set shelf time
3049 $self->put_hold_on_shelf($hold);
3050 $self->bail_on_events($self->editor->event)
3051 unless $self->editor->update_action_hold_request($hold);
3052 return if $self->bail_out;
3054 $self->notify_hold($hold_transit->hold);
3059 OpenILS::Event->new(
3062 payload => { transit => $transit, holdtransit => $hold_transit } ));
3064 return $hold_transit;
3068 # ------------------------------------------------------------------
3069 # Sets the shelf_time and shelf_expire_time for a newly shelved hold
3070 # ------------------------------------------------------------------
3071 sub put_hold_on_shelf {
3072 my($self, $hold) = @_;
3074 $hold->shelf_time('now');
3076 my $shelf_expire = $U->ou_ancestor_setting_value(
3077 $self->circ_lib, 'circ.holds.default_shelf_expire_interval', $self->editor);
3079 return undef unless $shelf_expire;
3081 my $seconds = OpenSRF::Utils->interval_to_seconds($shelf_expire);
3082 my $expire_time = DateTime->now->add(seconds => $seconds);
3084 # if the shelf expire time overlaps with a pickup lib's
3085 # closed date, push it out to the first open date
3086 my $dateinfo = $U->storagereq(
3087 'open-ils.storage.actor.org_unit.closed_date.overlap',
3088 $hold->pickup_lib, $expire_time);
3091 my $dt_parser = DateTime::Format::ISO8601->new;
3092 $expire_time = $dt_parser->parse_datetime(cleanse_ISO8601($dateinfo->{end}));
3094 # TODO: enable/disable time bump via setting?
3095 $expire_time->set(hour => '23', minute => '59', second => '59');
3097 $logger->info("circulator: shelf_expire_time overlaps".
3098 " with closed date, pushing expire time to $expire_time");
3101 $hold->shelf_expire_time($expire_time->strftime('%FT%T%z'));
3107 sub generate_fines {
3109 my $reservation = shift;
3111 $self->generate_fines_start($reservation);
3112 $self->generate_fines_finish($reservation);
3117 sub generate_fines_start {
3119 my $reservation = shift;
3120 my $dt_parser = DateTime::Format::ISO8601->new;
3122 my $obj = $reservation ? $self->reservation : $self->circ;
3124 # If we have a grace period
3125 if($obj->can('grace_period')) {
3126 # Parse out the due date
3127 my $due_date = $dt_parser->parse_datetime( cleanse_ISO8601($obj->due_date) );
3128 # Add the grace period to the due date
3129 $due_date->add(seconds => OpenSRF::Utils->interval_to_seconds($obj->grace_period));
3130 # Don't generate fines on circs still in grace period
3131 return undef if ($due_date > DateTime->now);
3134 if (!exists($self->{_gen_fines_req})) {
3135 $self->{_gen_fines_req} = OpenSRF::AppSession->create('open-ils.storage')
3137 'open-ils.storage.action.circulation.overdue.generate_fines',
3145 sub generate_fines_finish {
3147 my $reservation = shift;
3149 return undef unless $self->{_gen_fines_req};
3151 my $id = $reservation ? $self->reservation->id : $self->circ->id;
3153 $self->{_gen_fines_req}->wait_complete;
3154 delete($self->{_gen_fines_req});
3156 # refresh the circ in case the fine generator set the stop_fines field
3157 $self->reservation($self->editor->retrieve_booking_reservation($id)) if $reservation;
3158 $self->circ($self->editor->retrieve_action_circulation($id)) if !$reservation;
3163 sub checkin_handle_circ {
3165 my $circ = $self->circ;
3166 my $copy = $self->copy;
3170 $self->backdate($circ->xact_start) if $self->claims_never_checked_out;
3172 # backdate the circ if necessary
3173 if($self->backdate) {
3174 my $evt = $self->checkin_handle_backdate;
3175 return $self->bail_on_events($evt) if $evt;
3178 if(!$circ->stop_fines) {
3179 $circ->stop_fines(OILS_STOP_FINES_CHECKIN);
3180 $circ->stop_fines(OILS_STOP_FINES_RENEW) if $self->is_renewal;
3181 $circ->stop_fines(OILS_STOP_FINES_CLAIMS_NEVERCHECKEDOUT) if $self->claims_never_checked_out;
3182 $circ->stop_fines_time('now');
3183 $circ->stop_fines_time($self->backdate) if $self->backdate;
3186 # Set the checkin vars since we have the item
3187 $circ->checkin_time( ($self->backdate) ? $self->backdate : 'now' );
3189 # capture the true scan time for back-dated checkins
3190 $circ->checkin_scan_time('now');
3192 $circ->checkin_staff($self->editor->requestor->id);
3193 $circ->checkin_lib($self->circ_lib);
3194 $circ->checkin_workstation($self->editor->requestor->wsid);
3196 my $circ_lib = (ref $self->copy->circ_lib) ?
3197 $self->copy->circ_lib->id : $self->copy->circ_lib;
3198 my $stat = $U->copy_status($self->copy->status)->id;
3200 # immediately available keeps items lost or missing items from going home before being handled
3201 my $lost_immediately_available = $U->ou_ancestor_setting_value(
3202 $circ_lib, OILS_SETTING_LOST_IMMEDIATELY_AVAILABLE, $self->editor) || 0;
3205 if ( (!$lost_immediately_available) && ($circ_lib != $self->circ_lib) ) {
3207 if( ($stat == OILS_COPY_STATUS_LOST or $stat == OILS_COPY_STATUS_MISSING) ) {
3208 $logger->info("circulator: not updating copy status on checkin because copy is lost/missing");
3210 $self->copy->status($U->copy_status(OILS_COPY_STATUS_RESHELVING));
3214 } elsif ($stat == OILS_COPY_STATUS_LOST) {
3216 $self->checkin_handle_lost($circ_lib);
3220 $self->copy->status($U->copy_status(OILS_COPY_STATUS_RESHELVING));
3225 # see if there are any fines owed on this circ. if not, close it
3226 ($obt) = $U->fetch_mbts($circ->id, $self->editor);
3227 $circ->xact_finish('now') if( $obt and $obt->balance_owed == 0 );
3229 $logger->debug("circulator: ".$obt->balance_owed." is owed on this circulation");
3231 return $self->bail_on_events($self->editor->event)
3232 unless $self->editor->update_action_circulation($circ);
3238 # ------------------------------------------------------------------
3239 # See if we need to void billings for lost checkin
3240 # ------------------------------------------------------------------
3241 sub checkin_handle_lost {
3243 my $circ_lib = shift;
3244 my $circ = $self->circ;
3246 my $max_return = $U->ou_ancestor_setting_value(
3247 $circ_lib, OILS_SETTING_MAX_ACCEPT_RETURN_OF_LOST, $self->editor) || 0;
3252 my @tm = reverse($circ->due_date =~ /([\d\.]+)/og);
3253 $tm[5] -= 1 if $tm[5] > 0;
3254 my $due = timelocal(int($tm[1]), int($tm[2]), int($tm[3]), int($tm[4]), int($tm[5]), int($tm[6]));
3256 my $last_chance = OpenSRF::Utils->interval_to_seconds($max_return) + int($due);
3257 $logger->info("MAX OD: ".$max_return." DUEDATE: ".$circ->due_date." TODAY: ".$today." DUE: ".$due." LAST: ".$last_chance);
3259 $max_return = 0 if $today < $last_chance;
3262 if (!$max_return){ # there's either no max time to accept returns defined or we're within that time
3264 my $void_lost = $U->ou_ancestor_setting_value(
3265 $circ_lib, OILS_SETTING_VOID_LOST_ON_CHECKIN, $self->editor) || 0;
3266 my $void_lost_fee = $U->ou_ancestor_setting_value(
3267 $circ_lib, OILS_SETTING_VOID_LOST_PROCESS_FEE_ON_CHECKIN, $self->editor) || 0;
3268 my $restore_od = $U->ou_ancestor_setting_value(
3269 $circ_lib, OILS_SETTING_RESTORE_OVERDUE_ON_LOST_RETURN, $self->editor) || 0;
3270 $self->generate_lost_overdue(1) if $U->ou_ancestor_setting_value(
3271 $circ_lib, OILS_SETTING_GENERATE_OVERDUE_ON_LOST_RETURN, $self->editor);
3273 $self->checkin_handle_lost_now_found(3) if $void_lost;
3274 $self->checkin_handle_lost_now_found(4) if $void_lost_fee;
3275 $self->checkin_handle_lost_now_found_restore_od($circ_lib) if $restore_od && ! $self->void_overdues;
3278 $self->copy->status($U->copy_status(OILS_COPY_STATUS_RESHELVING));
3283 sub checkin_handle_backdate {
3286 # ------------------------------------------------------------------
3287 # clean up the backdate for date comparison
3288 # XXX We are currently taking the due-time from the original due-date,
3289 # not the input. Do we need to do this? This certainly interferes with
3290 # backdating of hourly checkouts, but that is likely a very rare case.
3291 # ------------------------------------------------------------------
3292 my $bd = cleanse_ISO8601($self->backdate);
3293 my $original_date = DateTime::Format::ISO8601->new->parse_datetime(cleanse_ISO8601($self->circ->due_date));
3294 my $new_date = DateTime::Format::ISO8601->new->parse_datetime($bd);
3295 $bd = cleanse_ISO8601($new_date->ymd . 'T' . $original_date->strftime('%T%z'));
3297 $self->backdate($bd);
3302 sub check_checkin_copy_status {
3304 my $copy = $self->copy;
3306 my $status = $U->copy_status($copy->status)->id;
3309 if( $status == OILS_COPY_STATUS_AVAILABLE ||
3310 $status == OILS_COPY_STATUS_CHECKED_OUT ||
3311 $status == OILS_COPY_STATUS_IN_PROCESS ||
3312 $status == OILS_COPY_STATUS_ON_HOLDS_SHELF ||
3313 $status == OILS_COPY_STATUS_IN_TRANSIT ||
3314 $status == OILS_COPY_STATUS_CATALOGING ||
3315 $status == OILS_COPY_STATUS_ON_RESV_SHELF ||
3316 $status == OILS_COPY_STATUS_RESHELVING );
3318 return OpenILS::Event->new('COPY_STATUS_LOST', payload => $copy )
3319 if( $status == OILS_COPY_STATUS_LOST );
3321 return OpenILS::Event->new('COPY_STATUS_MISSING', payload => $copy )
3322 if( $status == OILS_COPY_STATUS_MISSING );
3324 return OpenILS::Event->new('COPY_BAD_STATUS', payload => $copy );
3329 # --------------------------------------------------------------------------
3330 # On checkin, we need to return as many relevant objects as we can
3331 # --------------------------------------------------------------------------
3332 sub checkin_flesh_events {
3335 if( grep { $_->{textcode} eq 'SUCCESS' } @{$self->events}
3336 and grep { $_->{textcode} eq 'ITEM_NOT_CATALOGED' } @{$self->events} ) {
3337 $self->events([grep { $_->{textcode} eq 'ITEM_NOT_CATALOGED' } @{$self->events}]);
3340 my $record = $U->record_to_mvr($self->title) if($self->title and !$self->is_precat);
3343 if($self->hold and !$self->hold->cancel_time) {
3344 $hold = $self->hold;
3345 $hold->notes($self->editor->search_action_hold_request_note({hold => $hold->id}));
3349 # if we checked in a circulation, flesh the billing summary data
3350 $self->circ->billable_transaction(
3351 $self->editor->retrieve_money_billable_transaction([
3353 {flesh => 1, flesh_fields => {mbt => ['summary']}}
3359 # flesh some patron fields before returning
3361 $self->editor->retrieve_actor_user([
3366 au => ['card', 'billing_address', 'mailing_address']
3373 for my $evt (@{$self->events}) {
3376 $payload->{copy} = $U->unflesh_copy($self->copy);
3377 $payload->{volume} = $self->volume;
3378 $payload->{record} = $record,
3379 $payload->{circ} = $self->circ;
3380 $payload->{transit} = $self->transit;
3381 $payload->{cancelled_hold_transit} = 1 if $self->cancelled_hold_transit;
3382 $payload->{hold} = $hold;
3383 $payload->{patron} = $self->patron;
3384 $payload->{reservation} = $self->reservation
3385 unless (not $self->reservation or $self->reservation->cancel_time);
3387 $evt->{payload} = $payload;
3392 my( $self, $msg ) = @_;
3393 my $bc = ($self->copy) ? $self->copy->barcode :
3396 my $usr = ($self->patron) ? $self->patron->id : "";
3397 $logger->info("circulator: $msg requestor=".$self->editor->requestor->id.
3398 ", recipient=$usr, copy=$bc");
3404 $self->log_me("do_renew()");
3406 # Make sure there is an open circ to renew that is not
3407 # marked as LOST, CLAIMSRETURNED, or LONGOVERDUE
3408 my $usrid = $self->patron->id if $self->patron;
3409 my $circ = $self->editor->search_action_circulation({
3410 target_copy => $self->copy->id,
3411 xact_finish => undef,
3412 checkin_time => undef,
3413 ($usrid ? (usr => $usrid) : ()),
3415 {stop_fines => undef},
3416 {stop_fines => OILS_STOP_FINES_MAX_FINES}
3420 return $self->bail_on_events($self->editor->event) unless $circ;
3422 # A user is not allowed to renew another user's items without permission
3423 unless( $circ->usr eq $self->editor->requestor->id ) {
3424 return $self->bail_on_events($self->editor->events)
3425 unless $self->editor->allowed('RENEW_CIRC', $circ->circ_lib);
3428 $self->push_events(OpenILS::Event->new('MAX_RENEWALS_REACHED'))
3429 if $circ->renewal_remaining < 1;
3431 # -----------------------------------------------------------------
3433 $self->parent_circ($circ->id);
3434 $self->renewal_remaining( $circ->renewal_remaining - 1 );
3437 # Opac renewal - re-use circ library from original circ (unless told not to)
3438 if($self->opac_renewal) {
3439 unless(defined($opac_renewal_use_circ_lib)) {
3440 my $use_circ_lib = $self->editor->retrieve_config_global_flag('circ.opac_renewal.use_original_circ_lib');
3441 if($use_circ_lib and $U->is_true($use_circ_lib->enabled)) {
3442 $opac_renewal_use_circ_lib = 1;
3445 $opac_renewal_use_circ_lib = 0;
3448 $self->circ_lib($circ->circ_lib) if($opac_renewal_use_circ_lib);
3451 # Run the fine generator against the old circ
3452 $self->generate_fines_start;
3454 $self->run_renew_permit;
3457 $self->do_checkin();
3458 return if $self->bail_out;
3460 unless( $self->permit_override ) {
3462 return if $self->bail_out;
3463 $self->is_precat(1) if $self->have_event('ITEM_NOT_CATALOGED');
3464 $self->remove_event('ITEM_NOT_CATALOGED');
3467 $self->override_events;
3468 return if $self->bail_out;
3471 $self->do_checkout();
3476 my( $self, $evt ) = @_;
3477 $evt = (ref $evt) ? $evt->{textcode} : $evt;
3478 $logger->debug("circulator: removing event from list: $evt");
3479 my @events = @{$self->events};
3480 $self->events( [ grep { $_->{textcode} ne $evt } @events ] );
3485 my( $self, $evt ) = @_;
3486 $evt = (ref $evt) ? $evt->{textcode} : $evt;
3487 return grep { $_->{textcode} eq $evt } @{$self->events};
3492 sub run_renew_permit {
3495 if ($U->ou_ancestor_setting_value($self->circ_lib, 'circ.block_renews_for_holds')) {
3496 my ($hold, undef, $retarget) = $holdcode->find_nearest_permitted_hold(
3497 $self->editor, $self->copy, $self->editor->requestor, 1
3499 $self->push_events(new OpenILS::Event("COPY_NEEDED_FOR_HOLD")) if $hold;
3502 if(!$self->legacy_script_support) {
3503 my $results = $self->run_indb_circ_test;
3504 $self->push_events($self->matrix_test_result_events)
3505 unless $self->circ_test_success;
3508 my $runner = $self->script_runner;
3510 $runner->load($self->circ_permit_renew);
3511 my $result = $runner->run or
3512 throw OpenSRF::EX::ERROR ("Circ Permit Renew Script Died: $@");
3513 if ($result->{"events"}) {
3515 map { new OpenILS::Event($_) } @{$result->{"events"}}
3518 "circulator: circ_permit_renew for user " .
3519 $self->patron->id . " returned " .
3520 scalar(@{$result->{"events"}}) . " event(s)"
3524 $self->mk_script_runner;
3527 $logger->debug("circulator: re-creating script runner to be safe");
3531 # XXX: The primary mechanism for storing circ history is now handled
3532 # by tracking real circulation objects instead of bibs in a bucket.
3533 # However, this code is disabled by default and could be useful
3534 # some day, so may as well leave it for now.
3535 sub append_reading_list {
3539 $self->is_checkout and
3545 # verify history is globally enabled and uses the bucket mechanism
3546 my $htype = OpenSRF::Utils::SettingsClient->new->config_value(
3547 apps => 'open-ils.circ' => app_settings => 'checkout_history_mechanism');
3549 return undef unless $htype and $htype eq 'bucket';
3551 my $e = new_editor(xact => 1, requestor => $self->editor->requestor);
3553 # verify the patron wants to retain the hisory
3554 my $setting = $e->search_actor_user_setting(
3555 {usr => $self->patron->id, name => 'circ.keep_checkout_history'})->[0];
3557 unless($setting and $setting->value) {
3562 my $bkt = $e->search_container_copy_bucket(
3563 {owner => $self->patron->id, btype => 'circ_history'})->[0];
3568 # find the next item position
3569 my $last_item = $e->search_container_copy_bucket_item(
3570 {bucket => $bkt->id}, {order_by => {ccbi => 'pos desc'}, limit => 1})->[0];
3571 $pos = $last_item->pos + 1 if $last_item;
3574 # create the history bucket if necessary
3575 $bkt = Fieldmapper::container::copy_bucket->new;
3576 $bkt->owner($self->patron->id);
3578 $bkt->btype('circ_history');
3580 $e->create_container_copy_bucket($bkt) or return $e->die_event;
3583 my $item = Fieldmapper::container::copy_bucket_item->new;
3585 $item->bucket($bkt->id);
3586 $item->target_copy($self->copy->id);
3589 $e->create_container_copy_bucket_item($item) or return $e->die_event;
3596 sub make_trigger_events {
3598 return unless $self->circ;
3599 $U->create_events_for_hook('checkout', $self->circ, $self->circ_lib) if $self->is_checkout;
3600 $U->create_events_for_hook('checkin', $self->circ, $self->circ_lib) if $self->is_checkin;
3601 $U->create_events_for_hook('renewal', $self->circ, $self->circ_lib) if $self->is_renewal;
3606 sub checkin_handle_lost_now_found {
3607 my ($self, $bill_type) = @_;
3609 # ------------------------------------------------------------------
3610 # remove charge from patron's account if lost item is returned
3611 # ------------------------------------------------------------------
3613 my $bills = $self->editor->search_money_billing(
3615 xact => $self->circ->id,
3620 $logger->debug("voiding lost item charge of ".scalar(@$bills));
3621 for my $bill (@$bills) {
3622 if( !$U->is_true($bill->voided) ) {
3623 $logger->info("lost item returned - voiding bill ".$bill->id);
3625 $bill->void_time('now');
3626 $bill->voider($self->editor->requestor->id);
3627 my $note = ($bill->note) ? $bill->note . "\n" : '';
3628 $bill->note("${note}System: VOIDED FOR LOST ITEM RETURNED");
3630 $self->bail_on_events($self->editor->event)
3631 unless $self->editor->update_money_billing($bill);
3636 sub checkin_handle_lost_now_found_restore_od {
3638 my $circ_lib = shift;
3640 # ------------------------------------------------------------------
3641 # restore those overdue charges voided when item was set to lost
3642 # ------------------------------------------------------------------
3644 my $ods = $self->editor->search_money_billing(
3646 xact => $self->circ->id,
3651 $logger->debug("returning overdue charges pre-lost ".scalar(@$ods));
3652 for my $bill (@$ods) {
3653 if( $U->is_true($bill->voided) ) {
3654 $logger->info("lost item returned - restoring overdue ".$bill->id);
3656 $bill->clear_void_time;
3657 $bill->voider($self->editor->requestor->id);
3658 my $note = ($bill->note) ? $bill->note . "\n" : '';
3659 $bill->note("${note}System: LOST RETURNED - OVERDUES REINSTATED");
3661 $self->bail_on_events($self->editor->event)
3662 unless $self->editor->update_money_billing($bill);
3667 # ------------------------------------------------------------------
3668 # Lost-then-found item checked in. This sub generates new overdue
3669 # fines, beyond the point of any existing and possibly voided
3670 # overdue fines, up to the point of final checkin time (or max fine
3672 # ------------------------------------------------------------------
3673 sub generate_lost_overdue_fines {
3675 my $circ = $self->circ;
3676 my $e = $self->editor;
3678 # Re-open the transaction so the fine generator can see it
3679 if($circ->xact_finish or $circ->stop_fines) {
3681 $circ->clear_xact_finish;
3682 $circ->clear_stop_fines;
3683 $circ->clear_stop_fines_time;
3684 $e->update_action_circulation($circ) or return $e->die_event;
3688 $e->xact_begin; # generate_fines expects an in-xact editor
3689 $self->generate_fines;
3690 $circ = $self->circ; # generate fines re-fetches the circ
3694 # Re-close the transaction if no money is owed
3695 my ($obt) = $U->fetch_mbts($circ->id, $e);
3696 if ($obt and $obt->balance_owed == 0) {
3697 $circ->xact_finish('now');
3701 # Set stop fines if the fine generator didn't have to
3702 unless($circ->stop_fines) {
3703 $circ->stop_fines(OILS_STOP_FINES_CHECKIN);
3704 $circ->stop_fines_time('now');
3708 # update the event data sent to the caller within the transaction
3709 $self->checkin_flesh_events;
3712 $e->update_action_circulation($circ) or return $e->die_event;