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
546 my $type = ref($self) or die "$self is not an object";
548 my $name = $AUTOLOAD;
551 unless (grep { $_ eq $name } @AUTOLOAD_FIELDS) {
552 $logger->error("circulator: $type: invalid autoload field: $name");
553 die "$type: invalid autoload field: $name\n"
558 *{"${type}::${name}"} = sub {
561 $s->{$name} = $v if defined $v;
565 return $self->$name($data);
570 my( $class, $auth, %args ) = @_;
571 $class = ref($class) || $class;
572 my $self = bless( {}, $class );
575 $self->editor(new_editor(xact => 1, authtoken => $auth));
577 unless( $self->editor->checkauth ) {
578 $self->bail_on_events($self->editor->event);
582 $self->cache_handle(OpenSRF::Utils::Cache->new('global'));
584 $self->$_($args{$_}) for keys %args;
587 ($self->circ_lib) ? $self->circ_lib : $self->editor->requestor->ws_ou);
589 # if this is a renewal, default to desk_renewal
590 $self->desk_renewal(1) unless
591 $self->opac_renewal or $self->phone_renewal or $self->sip_renewal;
593 $self->capture('') unless $self->capture;
595 unless(%user_groups) {
596 my $gps = $self->editor->retrieve_all_permission_grp_tree;
597 %user_groups = map { $_->id => $_ } @$gps;
604 # --------------------------------------------------------------------------
605 # True if we should discontinue processing
606 # --------------------------------------------------------------------------
608 my( $self, $bool ) = @_;
609 if( defined $bool ) {
610 $logger->info("circulator: BAILING OUT") if $bool;
611 $self->{bail_out} = $bool;
613 return $self->{bail_out};
618 my( $self, @evts ) = @_;
621 $e->{payload} = $self->copy if
622 ($e->{textcode} eq 'COPY_NOT_AVAILABLE');
624 $logger->info("circulator: pushing event ".$e->{textcode});
625 push( @{$self->events}, $e ) unless
626 grep { $_->{textcode} eq $e->{textcode} } @{$self->events};
632 return '' if $self->skip_permit_key;
633 my $key = md5_hex( time() . rand() . "$$" );
634 $self->cache_handle->put_cache( "oils_permit_key_$key", 1, 300 );
635 return $self->permit_key($key);
638 sub check_permit_key {
640 return 1 if $self->skip_permit_key;
641 my $key = $self->permit_key;
642 return 0 unless $key;
643 my $k = "oils_permit_key_$key";
644 my $one = $self->cache_handle->get_cache($k);
645 $self->cache_handle->delete_cache($k);
646 return ($one) ? 1 : 0;
649 sub seems_like_reservation {
652 # Some words about the following method:
653 # 1) It requires the VIEW_USER permission, but that's not an
654 # issue, right, since all staff should have that?
655 # 2) It returns only one reservation at a time, even if an item can be
656 # and is currently overbooked. Hmmm....
657 my $booking_ses = create OpenSRF::AppSession("open-ils.booking");
658 my $result = $booking_ses->request(
659 "open-ils.booking.reservations.by_returnable_resource_barcode",
660 $self->editor->authtoken,
663 $booking_ses->disconnect;
665 return $self->bail_on_events($result) if defined $U->event_code($result);
668 $self->reservation(shift @$result);
676 # save_trimmed_copy() used just to be a block in mk_env(), but was separated for re-use
677 sub save_trimmed_copy {
678 my ($self, $copy) = @_;
681 $self->volume($copy->call_number);
682 $self->title($self->volume->record);
683 $self->copy->call_number($self->volume->id);
684 $self->volume->record($self->title->id);
685 $self->is_precat(1) if $self->volume->id == OILS_PRECAT_CALL_NUMBER;
686 if($self->copy->deposit_amount and $self->copy->deposit_amount > 0) {
687 $self->is_deposit(1) if $U->is_true($self->copy->deposit);
688 $self->is_rental(1) unless $U->is_true($self->copy->deposit);
694 my $e = $self->editor;
696 # --------------------------------------------------------------------------
697 # Grab the fleshed copy
698 # --------------------------------------------------------------------------
699 unless($self->is_noncat) {
702 $copy = $e->retrieve_asset_copy(
703 [$self->copy_id, $MK_ENV_FLESH ]) or return $e->event;
705 } elsif( $self->copy_barcode ) {
707 $copy = $e->search_asset_copy(
708 [{barcode => $self->copy_barcode, deleted => 'f'}, $MK_ENV_FLESH ])->[0];
709 } elsif( $self->reservation ) {
710 my $res = $e->json_query(
712 "select" => {"acp" => ["id"]},
717 "field" => "barcode",
721 "field" => "current_resource"
729 "id" => (ref $self->reservation) ?
730 $self->reservation->id : $self->reservation
735 if (ref $res eq "ARRAY" and scalar @$res) {
736 $logger->info("circulator: mapped reservation " .
737 $self->reservation . " to copy " . $res->[0]->{"id"});
738 $copy = $e->retrieve_asset_copy([$res->[0]->{"id"}, $MK_ENV_FLESH]);
743 $self->save_trimmed_copy($copy);
745 # We can't renew if there is no copy
746 return $self->bail_on_events(OpenILS::Event->new('ASSET_COPY_NOT_FOUND'))
747 if $self->is_renewal;
752 # --------------------------------------------------------------------------
754 # --------------------------------------------------------------------------
758 flesh_fields => {au => [ qw/ card / ]}
761 if( $self->patron_id ) {
762 $patron = $e->retrieve_actor_user([$self->patron_id, $flesh])
763 or return $self->bail_on_events(OpenILS::Event->new('ACTOR_USER_NOT_FOUND'));
765 } elsif( $self->patron_barcode ) {
767 # note: throwing ACTOR_USER_NOT_FOUND instead of ACTOR_CARD_NOT_FOUND is intentional
768 my $card = $e->search_actor_card({barcode => $self->patron_barcode})->[0]
769 or return $self->bail_on_events(OpenILS::Event->new('ACTOR_USER_NOT_FOUND'));
771 $patron = $e->retrieve_actor_user($card->usr)
772 or return $self->bail_on_events(OpenILS::Event->new('ACTOR_USER_NOT_FOUND'));
774 # Use the card we looked up, not the patron's primary, for card active checks
775 $patron->card($card);
778 if( my $copy = $self->copy ) {
781 $flesh->{flesh_fields}->{circ} = ['usr'];
783 my $circ = $e->search_action_circulation([
784 {target_copy => $copy->id, checkin_time => undef}, $flesh
788 $patron = $circ->usr;
789 $circ->usr($patron->id); # de-flesh for consistency
795 return $self->bail_on_events(OpenILS::Event->new('ACTOR_USER_NOT_FOUND'))
796 unless $self->patron($patron) or $self->is_checkin;
798 unless($self->is_checkin) {
800 # Check for inactivity and patron reg. expiration
802 $self->bail_on_events(OpenILS::Event->new('PATRON_INACTIVE'))
803 unless $U->is_true($patron->active);
805 $self->bail_on_events(OpenILS::Event->new('PATRON_CARD_INACTIVE'))
806 unless $U->is_true($patron->card->active);
808 my $expire = DateTime::Format::ISO8601->new->parse_datetime(
809 cleanse_ISO8601($patron->expire_date));
811 $self->bail_on_events(OpenILS::Event->new('PATRON_ACCOUNT_EXPIRED'))
812 if( CORE::time > $expire->epoch ) ;
816 # --------------------------------------------------------------------------
817 # This builds the script runner environment and fetches most of the
819 # --------------------------------------------------------------------------
820 sub mk_script_runner {
826 qw/copy copy_barcode copy_id patron
827 patron_id patron_barcode volume title editor/;
829 # Translate our objects into the ScriptBuilder args hash
830 $$args{$_} = $self->$_() for @fields;
832 $args->{ignore_user_status} = 1 if $self->is_checkin;
833 $$args{fetch_patron_by_circ_copy} = 1;
834 $$args{fetch_patron_circ_info} = 1 unless $self->is_checkin;
836 if( my $pco = $self->pending_checkouts ) {
837 $logger->info("circulator: we were given a pending checkouts number of $pco");
838 $$args{patronItemsOut} = $pco;
841 # This fetches most of the objects we need
842 $self->script_runner(
843 OpenILS::Application::Circ::ScriptBuilder->build($args));
845 # Now we translate the ScriptBuilder objects back into self
846 $self->$_($$args{$_}) for @fields;
848 my @evts = @{$args->{_events}} if $args->{_events};
850 $logger->debug("circulator: script builder returned events: @evts") if @evts;
854 # Anything besides ASSET_COPY_NOT_FOUND will stop processing
855 if(!$self->is_noncat and
857 $evts[0]->{textcode} eq 'ASSET_COPY_NOT_FOUND') {
861 my @e = grep { $_->{textcode} ne 'ASSET_COPY_NOT_FOUND' } @evts;
862 return $self->bail_on_events(@e);
867 $self->is_precat(1) if $self->copy->call_number == OILS_PRECAT_CALL_NUMBER;
868 if($self->copy->deposit_amount and $self->copy->deposit_amount > 0) {
869 $self->is_deposit(1) if $U->is_true($self->copy->deposit);
870 $self->is_rental(1) unless $U->is_true($self->copy->deposit);
874 # We can't renew if there is no copy
875 return $self->bail_on_events(@evts) if
876 $self->is_renewal and !$self->copy;
878 # Set some circ-specific flags in the script environment
879 my $evt = "environment";
880 $self->script_runner->insert("$evt.isRenewal", ($self->is_renewal) ? 1 : undef);
882 if( $self->is_noncat ) {
883 $self->script_runner->insert("$evt.isNonCat", 1);
884 $self->script_runner->insert("$evt.nonCatType", $self->noncat_type);
887 if( $self->is_precat ) {
888 $self->script_runner->insert("environment.isPrecat", 1, 1);
891 $self->script_runner->add_path( $_ ) for @$script_libs;
896 # --------------------------------------------------------------------------
897 # Does the circ permit work
898 # --------------------------------------------------------------------------
902 $self->log_me("do_permit()");
904 unless( $self->editor->requestor->id == $self->patron->id ) {
905 return $self->bail_on_events($self->editor->event)
906 unless( $self->editor->allowed('VIEW_PERMIT_CHECKOUT') );
909 $self->check_captured_holds();
910 $self->do_copy_checks();
911 return if $self->bail_out;
912 $self->run_patron_permit_scripts();
913 $self->run_copy_permit_scripts()
914 unless $self->is_precat or $self->is_noncat;
915 $self->check_item_deposit_events();
916 $self->override_events();
917 return if $self->bail_out;
919 if($self->is_precat and not $self->request_precat) {
922 'ITEM_NOT_CATALOGED', payload => $self->mk_permit_key));
923 return $self->bail_out(1) unless $self->is_renewal;
927 OpenILS::Event->new('SUCCESS', payload => $self->mk_permit_key));
930 sub check_item_deposit_events {
932 $self->push_events(OpenILS::Event->new('ITEM_DEPOSIT_REQUIRED', payload => $self->copy))
933 if $self->is_deposit and not $self->is_deposit_exempt;
934 $self->push_events(OpenILS::Event->new('ITEM_RENTAL_FEE_REQUIRED', payload => $self->copy))
935 if $self->is_rental and not $self->is_rental_exempt;
938 # returns true if the user is not required to pay deposits
939 sub is_deposit_exempt {
941 my $pid = (ref $self->patron->profile) ?
942 $self->patron->profile->id : $self->patron->profile;
943 my $groups = $U->ou_ancestor_setting_value(
944 $self->circ_lib, 'circ.deposit.exempt_groups', $self->editor);
945 for my $grp (@$groups) {
946 return 1 if $self->is_group_descendant($grp, $pid);
951 # returns true if the user is not required to pay rental fees
952 sub is_rental_exempt {
954 my $pid = (ref $self->patron->profile) ?
955 $self->patron->profile->id : $self->patron->profile;
956 my $groups = $U->ou_ancestor_setting_value(
957 $self->circ_lib, 'circ.rental.exempt_groups', $self->editor);
958 for my $grp (@$groups) {
959 return 1 if $self->is_group_descendant($grp, $pid);
964 sub is_group_descendant {
965 my($self, $p_id, $c_id) = @_;
966 return 0 unless defined $p_id and defined $c_id;
967 return 1 if $c_id == $p_id;
968 while(my $grp = $user_groups{$c_id}) {
969 $c_id = $grp->parent;
970 return 0 unless defined $c_id;
971 return 1 if $c_id == $p_id;
976 sub check_captured_holds {
978 my $copy = $self->copy;
979 my $patron = $self->patron;
981 return undef unless $copy;
983 my $s = $U->copy_status($copy->status)->id;
984 return unless $s == OILS_COPY_STATUS_ON_HOLDS_SHELF;
985 $logger->info("circulator: copy is on holds shelf, searching for the correct hold");
987 # Item is on the holds shelf, make sure it's going to the right person
988 my $holds = $self->editor->search_action_hold_request(
991 current_copy => $copy->id ,
992 capture_time => { '!=' => undef },
993 cancel_time => undef,
994 fulfillment_time => undef
1000 if( $holds and $$holds[0] ) {
1001 return undef if $$holds[0]->usr == $patron->id;
1004 $logger->info("circulator: this copy is needed by a different patron to fulfill a hold");
1006 $self->push_events(OpenILS::Event->new('ITEM_ON_HOLDS_SHELF'));
1010 sub do_copy_checks {
1012 my $copy = $self->copy;
1013 return unless $copy;
1015 my $stat = $U->copy_status($copy->status)->id;
1017 # We cannot check out a copy if it is in-transit
1018 if( $stat == OILS_COPY_STATUS_IN_TRANSIT ) {
1019 return $self->bail_on_events(OpenILS::Event->new('COPY_IN_TRANSIT'));
1022 $self->handle_claims_returned();
1023 return if $self->bail_out;
1025 # no claims returned circ was found, check if there is any open circ
1026 unless( $self->is_renewal ) {
1028 my $circs = $self->editor->search_action_circulation(
1029 { target_copy => $copy->id, checkin_time => undef }
1032 if(my $old_circ = $circs->[0]) { # an open circ was found
1034 my $payload = {copy => $copy};
1036 if($old_circ->usr == $self->patron->id) {
1038 $payload->{old_circ} = $old_circ;
1040 # If there is an open circulation on the checkout item and an auto-renew
1041 # interval is defined, inform the caller that they should go
1042 # ahead and renew the item instead of warning about open circulations.
1044 my $auto_renew_intvl = $U->ou_ancestor_setting_value(
1046 'circ.checkout_auto_renew_age',
1050 if($auto_renew_intvl) {
1051 my $intvl_seconds = OpenSRF::Utils->interval_to_seconds($auto_renew_intvl);
1052 my $checkout_time = DateTime::Format::ISO8601->new->parse_datetime( cleanse_ISO8601($old_circ->xact_start) );
1054 if(DateTime->now > $checkout_time->add(seconds => $intvl_seconds)) {
1055 $payload->{auto_renew} = 1;
1060 return $self->bail_on_events(
1061 OpenILS::Event->new('OPEN_CIRCULATION_EXISTS', payload => $payload)
1067 my $LEGACY_CIRC_EVENT_MAP = {
1068 'no_item' => 'ITEM_NOT_CATALOGED',
1069 'actor.usr.barred' => 'PATRON_BARRED',
1070 'asset.copy.circulate' => 'COPY_CIRC_NOT_ALLOWED',
1071 'asset.copy.status' => 'COPY_NOT_AVAILABLE',
1072 'asset.copy_location.circulate' => 'COPY_CIRC_NOT_ALLOWED',
1073 'config.circ_matrix_test.circulate' => 'COPY_CIRC_NOT_ALLOWED',
1074 'config.circ_matrix_test.max_items_out' => 'PATRON_EXCEEDS_CHECKOUT_COUNT',
1075 'config.circ_matrix_test.max_overdue' => 'PATRON_EXCEEDS_OVERDUE_COUNT',
1076 'config.circ_matrix_test.max_fines' => 'PATRON_EXCEEDS_FINES',
1077 'config.circ_matrix_circ_mod_test' => 'PATRON_EXCEEDS_CHECKOUT_COUNT',
1081 # ---------------------------------------------------------------------
1082 # This pushes any patron-related events into the list but does not
1083 # set bail_out for any events
1084 # ---------------------------------------------------------------------
1085 sub run_patron_permit_scripts {
1087 my $runner = $self->script_runner;
1088 my $patronid = $self->patron->id;
1092 if(!$self->legacy_script_support) {
1094 my $results = $self->run_indb_circ_test;
1095 unless($self->circ_test_success) {
1096 # no_item result is OK during noncat checkout
1097 unless(@$results == 1 && $results->[0]->{fail_part} eq 'no_item' and $self->is_noncat) {
1098 push @allevents, $self->matrix_test_result_events;
1104 # ---------------------------------------------------------------------
1105 # # Now run the patron permit script
1106 # ---------------------------------------------------------------------
1107 $runner->load($self->circ_permit_patron);
1108 my $result = $runner->run or
1109 throw OpenSRF::EX::ERROR ("Circ Permit Patron Script Died: $@");
1111 my $patron_events = $result->{events};
1113 OpenILS::Utils::Penalty->calculate_penalties($self->editor, $self->patron->id, $self->circ_lib);
1114 my $mask = ($self->is_renewal) ? 'RENEW' : 'CIRC';
1115 my $penalties = OpenILS::Utils::Penalty->retrieve_penalties($self->editor, $patronid, $self->circ_lib, $mask);
1116 $penalties = $penalties->{fatal_penalties};
1118 for my $pen (@$penalties) {
1119 my $event = OpenILS::Event->new($pen->name);
1120 $event->{desc} = $pen->label;
1121 push(@allevents, $event);
1124 push(@allevents, OpenILS::Event->new($_)) for (@$patron_events);
1128 $_->{payload} = $self->copy if
1129 ($_->{textcode} eq 'COPY_NOT_AVAILABLE');
1132 $logger->info("circulator: permit_patron script returned events: @allevents") if @allevents;
1134 $self->push_events(@allevents);
1137 sub matrix_test_result_codes {
1139 map { $_->{"fail_part"} } @{$self->matrix_test_result};
1142 sub matrix_test_result_events {
1145 my $event = new OpenILS::Event(
1146 $LEGACY_CIRC_EVENT_MAP->{$_->{"fail_part"}} || $_->{"fail_part"}
1148 $event->{"payload"} = {"fail_part" => $_->{"fail_part"}};
1150 } (@{$self->matrix_test_result});
1153 sub run_indb_circ_test {
1155 return $self->matrix_test_result if $self->matrix_test_result;
1157 my $dbfunc = ($self->is_renewal) ?
1158 'action.item_user_renew_test' : 'action.item_user_circ_test';
1160 if( $self->is_precat && $self->request_precat) {
1161 $self->make_precat_copy;
1162 return if $self->bail_out;
1165 my $results = $self->editor->json_query(
1169 ($self->is_noncat or ($self->is_precat and !$self->override and !$self->is_renewal)) ? undef : $self->copy->id,
1175 $self->circ_test_success($U->is_true($results->[0]->{success}));
1177 if(my $mp = $results->[0]->{matchpoint}) {
1178 $logger->info("circulator: circ policy test found matchpoint built via rows " . $results->[0]->{buildrows});
1179 $self->circ_matrix_matchpoint($self->editor->retrieve_config_circ_matrix_matchpoint($mp));
1180 $self->circ_matrix_matchpoint->duration_rule($self->editor->retrieve_config_rules_circ_duration($results->[0]->{duration_rule}));
1181 if(defined($results->[0]->{renewals})) {
1182 $self->circ_matrix_matchpoint->duration_rule->max_renewals($results->[0]->{renewals});
1184 $self->circ_matrix_matchpoint->recurring_fine_rule($self->editor->retrieve_config_rules_recurring_fine($results->[0]->{recurring_fine_rule}));
1185 if(defined($results->[0]->{grace_period})) {
1186 $self->circ_matrix_matchpoint->recurring_fine_rule->grace_period($results->[0]->{grace_period});
1188 $self->circ_matrix_matchpoint->max_fine_rule($self->editor->retrieve_config_rules_max_fine($results->[0]->{max_fine_rule}));
1189 $self->circ_matrix_matchpoint->hard_due_date($self->editor->retrieve_config_hard_due_date($results->[0]->{hard_due_date}));
1192 return $self->matrix_test_result($results);
1195 # ---------------------------------------------------------------------
1196 # given a use and copy, this will calculate the circulation policy
1197 # parameters. Only works with in-db circ.
1198 # ---------------------------------------------------------------------
1202 return OpenILS::Event->new('ASSET_COPY_NOT_FOUND') unless $self->copy;
1204 $self->run_indb_circ_test;
1207 circ_test_success => $self->circ_test_success,
1208 failure_events => [],
1209 failure_codes => [],
1210 matchpoint => $self->circ_matrix_matchpoint
1213 unless($self->circ_test_success) {
1214 $results->{"failure_codes"} = [ $self->matrix_test_result_codes ];
1215 $results->{"failure_events"} = [ $self->matrix_test_result_events ];
1218 if($self->circ_matrix_matchpoint) {
1219 my $duration_rule = $self->circ_matrix_matchpoint->duration_rule;
1220 my $recurring_fine_rule = $self->circ_matrix_matchpoint->recurring_fine_rule;
1221 my $max_fine_rule = $self->circ_matrix_matchpoint->max_fine_rule;
1222 my $hard_due_date = $self->circ_matrix_matchpoint->hard_due_date;
1224 my $policy = $self->get_circ_policy(
1225 $duration_rule, $recurring_fine_rule, $max_fine_rule, $hard_due_date);
1227 $$results{$_} = $$policy{$_} for keys %$policy;
1233 # ---------------------------------------------------------------------
1234 # Loads the circ policy info for duration, recurring fine, and max
1235 # fine based on the current copy
1236 # ---------------------------------------------------------------------
1237 sub get_circ_policy {
1238 my($self, $duration_rule, $recurring_fine_rule, $max_fine_rule, $hard_due_date) = @_;
1241 duration_rule => $duration_rule->name,
1242 recurring_fine_rule => $recurring_fine_rule->name,
1243 max_fine_rule => $max_fine_rule->name,
1244 max_fine => $self->get_max_fine_amount($max_fine_rule),
1245 fine_interval => $recurring_fine_rule->recurrence_interval,
1246 renewal_remaining => $duration_rule->max_renewals,
1247 grace_period => $recurring_fine_rule->grace_period
1250 if($hard_due_date) {
1251 $policy->{duration_date_ceiling} = $hard_due_date->ceiling_date;
1252 $policy->{duration_date_ceiling_force} = $hard_due_date->forceto;
1255 $policy->{duration_date_ceiling} = undef;
1256 $policy->{duration_date_ceiling_force} = undef;
1259 $policy->{duration} = $duration_rule->shrt
1260 if $self->copy->loan_duration == OILS_CIRC_DURATION_SHORT;
1261 $policy->{duration} = $duration_rule->normal
1262 if $self->copy->loan_duration == OILS_CIRC_DURATION_NORMAL;
1263 $policy->{duration} = $duration_rule->extended
1264 if $self->copy->loan_duration == OILS_CIRC_DURATION_EXTENDED;
1266 $policy->{recurring_fine} = $recurring_fine_rule->low
1267 if $self->copy->fine_level == OILS_REC_FINE_LEVEL_LOW;
1268 $policy->{recurring_fine} = $recurring_fine_rule->normal
1269 if $self->copy->fine_level == OILS_REC_FINE_LEVEL_NORMAL;
1270 $policy->{recurring_fine} = $recurring_fine_rule->high
1271 if $self->copy->fine_level == OILS_REC_FINE_LEVEL_HIGH;
1276 sub get_max_fine_amount {
1278 my $max_fine_rule = shift;
1279 my $max_amount = $max_fine_rule->amount;
1281 # if is_percent is true then the max->amount is
1282 # use as a percentage of the copy price
1283 if ($U->is_true($max_fine_rule->is_percent)) {
1284 my $price = $U->get_copy_price($self->editor, $self->copy, $self->volume);
1285 $max_amount = $price * $max_fine_rule->amount / 100;
1287 $U->ou_ancestor_setting_value(
1289 'circ.max_fine.cap_at_price',
1293 my $price = $U->get_copy_price($self->editor, $self->copy, $self->volume);
1294 $max_amount = ( $price && $max_amount > $price ) ? $price : $max_amount;
1302 sub run_copy_permit_scripts {
1304 my $copy = $self->copy || return;
1305 my $runner = $self->script_runner;
1309 if(!$self->legacy_script_support) {
1310 my $results = $self->run_indb_circ_test;
1311 push @allevents, $self->matrix_test_result_events
1312 unless $self->circ_test_success;
1315 # ---------------------------------------------------------------------
1316 # Capture all of the copy permit events
1317 # ---------------------------------------------------------------------
1318 $runner->load($self->circ_permit_copy);
1319 my $result = $runner->run or
1320 throw OpenSRF::EX::ERROR ("Circ Permit Copy Script Died: $@");
1321 my $copy_events = $result->{events};
1323 # ---------------------------------------------------------------------
1324 # Now collect all of the events together
1325 # ---------------------------------------------------------------------
1326 push( @allevents, OpenILS::Event->new($_)) for @$copy_events;
1329 # See if this copy has an alert message
1330 my $ae = $self->check_copy_alert();
1331 push( @allevents, $ae ) if $ae;
1333 # uniquify the events
1334 my %hash = map { ($_->{ilsevent} => $_) } @allevents;
1335 @allevents = values %hash;
1337 $logger->info("circulator: permit_copy script returned events: @allevents") if @allevents;
1339 $self->push_events(@allevents);
1343 sub check_copy_alert {
1345 return undef if $self->is_renewal;
1346 return OpenILS::Event->new(
1347 'COPY_ALERT_MESSAGE', payload => $self->copy->alert_message)
1348 if $self->copy and $self->copy->alert_message;
1354 # --------------------------------------------------------------------------
1355 # If the call is overriding and has permissions to override every collected
1356 # event, the are cleared. Any event that the caller does not have
1357 # permission to override, will be left in the event list and bail_out will
1359 # XXX We need code in here to cancel any holds/transits on copies
1360 # that are being force-checked out
1361 # --------------------------------------------------------------------------
1362 sub override_events {
1364 my @events = @{$self->events};
1365 return unless @events;
1367 if(!$self->override) {
1368 return $self->bail_out(1)
1369 if( @events > 1 or $events[0]->{textcode} ne 'SUCCESS' );
1374 for my $e (@events) {
1375 my $tc = $e->{textcode};
1376 next if $tc eq 'SUCCESS';
1377 my $ov = "$tc.override";
1378 $logger->info("circulator: attempting to override event: $ov");
1380 return $self->bail_on_events($self->editor->event)
1381 unless( $self->editor->allowed($ov) );
1386 # --------------------------------------------------------------------------
1387 # If there is an open claimsreturn circ on the requested copy, close the
1388 # circ if overriding, otherwise bail out
1389 # --------------------------------------------------------------------------
1390 sub handle_claims_returned {
1392 my $copy = $self->copy;
1394 my $CR = $self->editor->search_action_circulation(
1396 target_copy => $copy->id,
1397 stop_fines => OILS_STOP_FINES_CLAIMSRETURNED,
1398 checkin_time => undef,
1402 return unless ($CR = $CR->[0]);
1406 # - If the caller has set the override flag, we will check the item in
1407 if($self->override) {
1409 $CR->checkin_time('now');
1410 $CR->checkin_scan_time('now');
1411 $CR->checkin_lib($self->circ_lib);
1412 $CR->checkin_workstation($self->editor->requestor->wsid);
1413 $CR->checkin_staff($self->editor->requestor->id);
1415 $evt = $self->editor->event
1416 unless $self->editor->update_action_circulation($CR);
1419 $evt = OpenILS::Event->new('CIRC_CLAIMS_RETURNED');
1422 $self->bail_on_events($evt) if $evt;
1427 # --------------------------------------------------------------------------
1428 # This performs the checkout
1429 # --------------------------------------------------------------------------
1433 $self->log_me("do_checkout()");
1435 # make sure perms are good if this isn't a renewal
1436 unless( $self->is_renewal ) {
1437 return $self->bail_on_events($self->editor->event)
1438 unless( $self->editor->allowed('COPY_CHECKOUT') );
1441 # verify the permit key
1442 unless( $self->check_permit_key ) {
1443 if( $self->permit_override ) {
1444 return $self->bail_on_events($self->editor->event)
1445 unless $self->editor->allowed('CIRC_PERMIT_OVERRIDE');
1447 return $self->bail_on_events(OpenILS::Event->new('CIRC_PERMIT_BAD_KEY'))
1451 # if this is a non-cataloged circ, build the circ and finish
1452 if( $self->is_noncat ) {
1453 $self->checkout_noncat;
1455 OpenILS::Event->new('SUCCESS',
1456 payload => { noncat_circ => $self->circ }));
1460 if( $self->is_precat ) {
1461 $self->make_precat_copy;
1462 return if $self->bail_out;
1464 } elsif( $self->copy->call_number == OILS_PRECAT_CALL_NUMBER ) {
1465 return $self->bail_on_events(OpenILS::Event->new('ITEM_NOT_CATALOGED'));
1468 $self->do_copy_checks;
1469 return if $self->bail_out;
1471 $self->run_checkout_scripts();
1472 return if $self->bail_out;
1474 $self->build_checkout_circ_object();
1475 return if $self->bail_out;
1477 my $modify_to_start = $self->booking_adjusted_due_date();
1478 return if $self->bail_out;
1480 $self->apply_modified_due_date($modify_to_start);
1481 return if $self->bail_out;
1483 return $self->bail_on_events($self->editor->event)
1484 unless $self->editor->create_action_circulation($self->circ);
1486 # refresh the circ to force local time zone for now
1487 $self->circ($self->editor->retrieve_action_circulation($self->circ->id));
1489 $self->copy->status(OILS_COPY_STATUS_CHECKED_OUT);
1491 return if $self->bail_out;
1493 $self->apply_deposit_fee();
1494 return if $self->bail_out;
1496 $self->handle_checkout_holds();
1497 return if $self->bail_out;
1499 # ------------------------------------------------------------------------------
1500 # Update the patron penalty info in the DB. Run it for permit-overrides
1501 # since the penalties are not updated during the permit phase
1502 # ------------------------------------------------------------------------------
1503 OpenILS::Utils::Penalty->calculate_penalties($self->editor, $self->patron->id, $self->circ_lib);
1505 my $record = $U->record_to_mvr($self->title) unless $self->is_precat;
1508 if($self->is_renewal) {
1509 # flesh the billing summary for the checked-in circ
1510 $pcirc = $self->editor->retrieve_action_circulation([
1512 {flesh => 2, flesh_fields => {circ => ['billable_transaction'], mbt => ['summary']}}
1517 OpenILS::Event->new('SUCCESS',
1519 copy => $U->unflesh_copy($self->copy),
1520 volume => $self->volume,
1521 circ => $self->circ,
1523 holds_fulfilled => $self->fulfilled_holds,
1524 deposit_billing => $self->deposit_billing,
1525 rental_billing => $self->rental_billing,
1526 parent_circ => $pcirc,
1527 patron => ($self->return_patron) ? $self->patron : undef,
1528 patron_money => $self->editor->retrieve_money_user_summary($self->patron->id)
1534 sub apply_deposit_fee {
1536 my $copy = $self->copy;
1538 ($self->is_deposit and not $self->is_deposit_exempt) or
1539 ($self->is_rental and not $self->is_rental_exempt);
1541 return if $self->is_deposit and $self->skip_deposit_fee;
1542 return if $self->is_rental and $self->skip_rental_fee;
1544 my $bill = Fieldmapper::money::billing->new;
1545 my $amount = $copy->deposit_amount;
1549 if($self->is_deposit) {
1550 $billing_type = OILS_BILLING_TYPE_DEPOSIT;
1552 $self->deposit_billing($bill);
1554 $billing_type = OILS_BILLING_TYPE_RENTAL;
1556 $self->rental_billing($bill);
1559 $bill->xact($self->circ->id);
1560 $bill->amount($amount);
1561 $bill->note(OILS_BILLING_NOTE_SYSTEM);
1562 $bill->billing_type($billing_type);
1563 $bill->btype($btype);
1564 $self->editor->create_money_billing($bill) or $self->bail_on_events($self->editor->event);
1566 $logger->info("circulator: charged $amount on checkout with billing type $billing_type");
1571 my $copy = $self->copy;
1573 my $stat = $copy->status if ref $copy->status;
1574 my $loc = $copy->location if ref $copy->location;
1575 my $circ_lib = $copy->circ_lib if ref $copy->circ_lib;
1577 $copy->status($stat->id) if $stat;
1578 $copy->location($loc->id) if $loc;
1579 $copy->circ_lib($circ_lib->id) if $circ_lib;
1580 $copy->editor($self->editor->requestor->id);
1581 $copy->edit_date('now');
1582 $copy->age_protect($copy->age_protect->id) if ref $copy->age_protect;
1584 return $self->bail_on_events($self->editor->event)
1585 unless $self->editor->update_asset_copy($self->copy);
1587 $copy->status($U->copy_status($copy->status));
1588 $copy->location($loc) if $loc;
1589 $copy->circ_lib($circ_lib) if $circ_lib;
1592 sub update_reservation {
1594 my $reservation = $self->reservation;
1596 my $usr = $reservation->usr;
1597 my $target_rt = $reservation->target_resource_type;
1598 my $target_r = $reservation->target_resource;
1599 my $current_r = $reservation->current_resource;
1601 $reservation->usr($usr->id) if ref $usr;
1602 $reservation->target_resource_type($target_rt->id) if ref $target_rt;
1603 $reservation->target_resource($target_r->id) if ref $target_r;
1604 $reservation->current_resource($current_r->id) if ref $current_r;
1606 return $self->bail_on_events($self->editor->event)
1607 unless $self->editor->update_booking_reservation($self->reservation);
1610 ($reservation, $evt) = $U->fetch_booking_reservation($reservation->id);
1611 $self->reservation($reservation);
1615 sub bail_on_events {
1616 my( $self, @evts ) = @_;
1617 $self->push_events(@evts);
1622 # ------------------------------------------------------------------------------
1623 # When an item is checked out, see if we can fulfill a hold for this patron
1624 # ------------------------------------------------------------------------------
1625 sub handle_checkout_holds {
1627 my $copy = $self->copy;
1628 my $patron = $self->patron;
1630 my $e = $self->editor;
1631 $self->fulfilled_holds([]);
1633 # pre/non-cats can't fulfill a hold
1634 return if $self->is_precat or $self->is_noncat;
1636 my $hold = $e->search_action_hold_request({
1637 current_copy => $copy->id ,
1638 cancel_time => undef,
1639 fulfillment_time => undef,
1641 {expire_time => undef},
1642 {expire_time => {'>' => 'now'}}
1646 if($hold and $hold->usr != $patron->id) {
1647 # reset the hold since the copy is now checked out
1649 $logger->info("circulator: un-targeting hold ".$hold->id.
1650 " because copy ".$copy->id." is getting checked out");
1652 $hold->clear_prev_check_time;
1653 $hold->clear_current_copy;
1654 $hold->clear_capture_time;
1655 $hold->clear_shelf_time;
1656 $hold->clear_shelf_expire_time;
1658 return $self->bail_on_event($e->event)
1659 unless $e->update_action_hold_request($hold);
1665 $hold = $self->find_related_user_hold($copy, $patron) or return;
1666 $logger->info("circulator: found related hold to fulfill in checkout");
1669 $logger->debug("circulator: checkout fulfilling hold " . $hold->id);
1671 # if the hold was never officially captured, capture it.
1672 $hold->current_copy($copy->id);
1673 $hold->capture_time('now') unless $hold->capture_time;
1674 $hold->fulfillment_time('now');
1675 $hold->fulfillment_staff($e->requestor->id);
1676 $hold->fulfillment_lib($self->circ_lib);
1678 return $self->bail_on_events($e->event)
1679 unless $e->update_action_hold_request($hold);
1681 $holdcode->delete_hold_copy_maps($e, $hold->id);
1682 return $self->fulfilled_holds([$hold->id]);
1686 # ------------------------------------------------------------------------------
1687 # If the circ.checkout_fill_related_hold setting is turned on and no hold for
1688 # the patron directly targets the checked out item, see if there is another hold
1689 # for the patron that could be fulfilled by the checked out item. Fulfill the
1690 # oldest hold and only fulfill 1 of them.
1692 # For "another hold":
1694 # First, check for one that the copy matches via hold_copy_map, ensuring that
1695 # *any* hold type that this copy could fill may end up filled.
1697 # Then, if circ.checkout_fill_related_hold_exact_match_only is not enabled, look
1698 # for a Title (T) or Volume (V) hold that matches the item. This allows items
1699 # that are non-requestable to count as capturing those hold types.
1700 # ------------------------------------------------------------------------------
1701 sub find_related_user_hold {
1702 my($self, $copy, $patron) = @_;
1703 my $e = $self->editor;
1705 return undef if $self->volume->id == OILS_PRECAT_CALL_NUMBER;
1707 return undef unless $U->ou_ancestor_setting_value(
1708 $self->circ_lib, 'circ.checkout_fills_related_hold', $e);
1710 # find the oldest unfulfilled hold that has not yet hit the holds shelf.
1712 select => {ahr => ['id']},
1724 fulfillment_time => undef,
1725 cancel_time => undef,
1727 {expire_time => undef},
1728 {expire_time => {'>' => 'now'}}
1732 target_copy => $self->copy->id
1735 order_by => {ahr => {request_time => {direction => 'asc'}}},
1739 my $hold_info = $e->json_query($args)->[0];
1740 return $e->retrieve_action_hold_request($hold_info->{id}) if $hold_info;
1741 return undef if $U->ou_ancestor_setting_value(
1742 $self->circ_lib, 'circ.checkout_fills_related_hold_exact_match_only', $e);
1744 # find the oldest unfulfilled hold that has not yet hit the holds shelf.
1746 select => {ahr => ['id']},
1751 fkey => 'current_copy',
1752 type => 'left' # there may be no current_copy
1759 fulfillment_time => undef,
1760 cancel_time => undef,
1762 {expire_time => undef},
1763 {expire_time => {'>' => 'now'}}
1770 target => $self->volume->id
1776 target => $self->title->id
1782 {id => undef}, # left-join copy may be nonexistent
1783 {status => {'!=' => OILS_COPY_STATUS_ON_HOLDS_SHELF}},
1787 order_by => {ahr => {request_time => {direction => 'asc'}}},
1791 $hold_info = $e->json_query($args)->[0];
1792 return $e->retrieve_action_hold_request($hold_info->{id}) if $hold_info;
1797 sub run_checkout_scripts {
1802 my $runner = $self->script_runner;
1811 my $hard_due_date_name;
1813 if(!$self->legacy_script_support) {
1814 $self->run_indb_circ_test();
1815 $duration = $self->circ_matrix_matchpoint->duration_rule;
1816 $recurring = $self->circ_matrix_matchpoint->recurring_fine_rule;
1817 $max_fine = $self->circ_matrix_matchpoint->max_fine_rule;
1818 $hard_due_date = $self->circ_matrix_matchpoint->hard_due_date;
1822 $runner->load($self->circ_duration);
1824 my $result = $runner->run or
1825 throw OpenSRF::EX::ERROR ("Circ Duration Script Died: $@");
1827 $duration_name = $result->{durationRule};
1828 $recurring_name = $result->{recurringFinesRule};
1829 $max_fine_name = $result->{maxFine};
1830 $hard_due_date_name = $result->{hardDueDate};
1833 $duration_name = $duration->name if $duration;
1834 if( $duration_name ne OILS_UNLIMITED_CIRC_DURATION ) {
1837 ($duration, $evt) = $U->fetch_circ_duration_by_name($duration_name);
1838 return $self->bail_on_events($evt) if ($evt && !$nobail);
1840 ($recurring, $evt) = $U->fetch_recurring_fine_by_name($recurring_name);
1841 return $self->bail_on_events($evt) if ($evt && !$nobail);
1843 ($max_fine, $evt) = $U->fetch_max_fine_by_name($max_fine_name);
1844 return $self->bail_on_events($evt) if ($evt && !$nobail);
1846 if($hard_due_date_name) {
1847 ($hard_due_date, $evt) = $U->fetch_hard_due_date_by_name($hard_due_date_name);
1848 return $self->bail_on_events($evt) if ($evt && !$nobail);
1854 # The item circulates with an unlimited duration
1858 $hard_due_date = undef;
1861 $self->duration_rule($duration);
1862 $self->recurring_fines_rule($recurring);
1863 $self->max_fine_rule($max_fine);
1864 $self->hard_due_date($hard_due_date);
1868 sub build_checkout_circ_object {
1871 my $circ = Fieldmapper::action::circulation->new;
1872 my $duration = $self->duration_rule;
1873 my $max = $self->max_fine_rule;
1874 my $recurring = $self->recurring_fines_rule;
1875 my $hard_due_date = $self->hard_due_date;
1876 my $copy = $self->copy;
1877 my $patron = $self->patron;
1878 my $duration_date_ceiling;
1879 my $duration_date_ceiling_force;
1883 my $policy = $self->get_circ_policy($duration, $recurring, $max, $hard_due_date);
1884 $duration_date_ceiling = $policy->{duration_date_ceiling};
1885 $duration_date_ceiling_force = $policy->{duration_date_ceiling_force};
1887 my $dname = $duration->name;
1888 my $mname = $max->name;
1889 my $rname = $recurring->name;
1891 if($hard_due_date) {
1892 $hdname = $hard_due_date->name;
1895 $logger->debug("circulator: building circulation ".
1896 "with duration=$dname, maxfine=$mname, recurring=$rname, hard due date=$hdname");
1898 $circ->duration($policy->{duration});
1899 $circ->recurring_fine($policy->{recurring_fine});
1900 $circ->duration_rule($duration->name);
1901 $circ->recurring_fine_rule($recurring->name);
1902 $circ->max_fine_rule($max->name);
1903 $circ->max_fine($policy->{max_fine});
1904 $circ->fine_interval($recurring->recurrence_interval);
1905 $circ->renewal_remaining($duration->max_renewals);
1906 $circ->grace_period($policy->{grace_period});
1910 $logger->info("circulator: copy found with an unlimited circ duration");
1911 $circ->duration_rule(OILS_UNLIMITED_CIRC_DURATION);
1912 $circ->recurring_fine_rule(OILS_UNLIMITED_CIRC_DURATION);
1913 $circ->max_fine_rule(OILS_UNLIMITED_CIRC_DURATION);
1914 $circ->renewal_remaining(0);
1915 $circ->grace_period(0);
1918 $circ->target_copy( $copy->id );
1919 $circ->usr( $patron->id );
1920 $circ->circ_lib( $self->circ_lib );
1921 $circ->workstation($self->editor->requestor->wsid)
1922 if defined $self->editor->requestor->wsid;
1924 # renewals maintain a link to the parent circulation
1925 $circ->parent_circ($self->parent_circ);
1927 if( $self->is_renewal ) {
1928 $circ->opac_renewal('t') if $self->opac_renewal;
1929 $circ->phone_renewal('t') if $self->phone_renewal;
1930 $circ->desk_renewal('t') if $self->desk_renewal;
1931 $circ->renewal_remaining($self->renewal_remaining);
1932 $circ->circ_staff($self->editor->requestor->id);
1936 # if the user provided an overiding checkout time,
1937 # (e.g. the checkout really happened several hours ago), then
1938 # we apply that here. Does this need a perm??
1939 $circ->xact_start(cleanse_ISO8601($self->checkout_time))
1940 if $self->checkout_time;
1942 # if a patron is renewing, 'requestor' will be the patron
1943 $circ->circ_staff($self->editor->requestor->id);
1944 $circ->due_date( $self->create_due_date($circ->duration, $duration_date_ceiling, $duration_date_ceiling_force) ) if $circ->duration;
1949 sub do_reservation_pickup {
1952 $self->log_me("do_reservation_pickup()");
1954 $self->reservation->pickup_time('now');
1957 $self->reservation->current_resource &&
1958 $U->is_true($self->reservation->target_resource_type->catalog_item)
1960 # We used to try to set $self->copy and $self->patron here,
1961 # but that should already be done.
1963 $self->run_checkout_scripts(1);
1965 my $duration = $self->duration_rule;
1966 my $max = $self->max_fine_rule;
1967 my $recurring = $self->recurring_fines_rule;
1969 if ($duration && $max && $recurring) {
1970 my $policy = $self->get_circ_policy($duration, $recurring, $max);
1972 my $dname = $duration->name;
1973 my $mname = $max->name;
1974 my $rname = $recurring->name;
1976 $logger->debug("circulator: updating reservation ".
1977 "with duration=$dname, maxfine=$mname, recurring=$rname");
1979 $self->reservation->fine_amount($policy->{recurring_fine});
1980 $self->reservation->max_fine($policy->{max_fine});
1981 $self->reservation->fine_interval($recurring->recurrence_interval);
1984 $self->copy->status(OILS_COPY_STATUS_CHECKED_OUT);
1985 $self->update_copy();
1988 $self->reservation->fine_amount(
1989 $self->reservation->target_resource_type->fine_amount
1991 $self->reservation->max_fine(
1992 $self->reservation->target_resource_type->max_fine
1994 $self->reservation->fine_interval(
1995 $self->reservation->target_resource_type->fine_interval
1999 $self->update_reservation();
2002 sub do_reservation_return {
2004 my $request = shift;
2006 $self->log_me("do_reservation_return()");
2008 if (not ref $self->reservation) {
2009 my ($reservation, $evt) =
2010 $U->fetch_booking_reservation($self->reservation);
2011 return $self->bail_on_events($evt) if $evt;
2012 $self->reservation($reservation);
2015 $self->generate_fines(1);
2016 $self->reservation->return_time('now');
2017 $self->update_reservation();
2018 $self->reshelve_copy if $self->copy;
2020 if ( $self->reservation->current_resource && $self->reservation->current_resource->catalog_item ) {
2021 $self->copy( $self->reservation->current_resource->catalog_item );
2025 sub booking_adjusted_due_date {
2027 my $circ = $self->circ;
2028 my $copy = $self->copy;
2030 return undef unless $self->use_booking;
2034 if( $self->due_date ) {
2036 return $self->bail_on_events($self->editor->event)
2037 unless $self->editor->allowed('CIRC_OVERRIDE_DUE_DATE', $self->circ_lib);
2039 $circ->due_date(cleanse_ISO8601($self->due_date));
2043 return unless $copy and $circ->due_date;
2046 my $booking_items = $self->editor->search_booking_resource( { barcode => $copy->barcode } );
2047 if (@$booking_items) {
2048 my $booking_item = $booking_items->[0];
2049 my $resource_type = $self->editor->retrieve_booking_resource_type( $booking_item->type );
2051 my $stop_circ_setting = $U->ou_ancestor_setting_value( $self->circ_lib, 'circ.booking_reservation.stop_circ', $self->editor );
2052 my $shorten_circ_setting = $resource_type->elbow_room ||
2053 $U->ou_ancestor_setting_value( $self->circ_lib, 'circ.booking_reservation.default_elbow_room', $self->editor ) ||
2056 my $booking_ses = OpenSRF::AppSession->create( 'open-ils.booking' );
2057 my $bookings = $booking_ses->request(
2058 'open-ils.booking.reservations.filtered_id_list', $self->editor->authtoken,
2059 { resource => $booking_item->id, search_start => 'now', search_end => $circ->due_date, fields => { cancel_time => undef, return_time => undef}}
2061 $booking_ses->disconnect;
2063 my $dt_parser = DateTime::Format::ISO8601->new;
2064 my $due_date = $dt_parser->parse_datetime( cleanse_ISO8601($circ->due_date) );
2066 for my $bid (@$bookings) {
2068 my $booking = $self->editor->retrieve_booking_reservation( $bid );
2070 my $booking_start = $dt_parser->parse_datetime( cleanse_ISO8601($booking->start_time) );
2071 my $booking_end = $dt_parser->parse_datetime( cleanse_ISO8601($booking->end_time) );
2073 return $self->bail_on_events( OpenILS::Event->new('COPY_RESERVED') )
2074 if ($booking_start < DateTime->now);
2077 if ($U->is_true($stop_circ_setting)) {
2078 $self->bail_on_events( OpenILS::Event->new('COPY_RESERVED') );
2080 $due_date = $booking_start->subtract( seconds => interval_to_seconds($shorten_circ_setting) );
2081 $self->bail_on_events( OpenILS::Event->new('COPY_RESERVED') ) if ($due_date < DateTime->now);
2084 # We set the circ duration here only to affect the logic that will
2085 # later (in a DB trigger) mangle the time part of the due date to
2086 # 11:59pm. Having any circ duration that is not a whole number of
2087 # days is enough to prevent the "correction."
2088 my $new_circ_duration = $due_date->epoch - time;
2089 $new_circ_duration++ if $new_circ_duration % 86400 == 0;
2090 $circ->duration("$new_circ_duration seconds");
2092 $circ->due_date(cleanse_ISO8601($due_date->strftime('%FT%T%z')));
2096 return $self->bail_on_events($self->editor->event)
2097 unless $self->editor->allowed('CIRC_OVERRIDE_DUE_DATE', $self->circ_lib);
2103 sub apply_modified_due_date {
2105 my $shift_earlier = shift;
2106 my $circ = $self->circ;
2107 my $copy = $self->copy;
2109 if( $self->due_date ) {
2111 return $self->bail_on_events($self->editor->event)
2112 unless $self->editor->allowed('CIRC_OVERRIDE_DUE_DATE', $self->circ_lib);
2114 $circ->due_date(cleanse_ISO8601($self->due_date));
2118 # if the due_date lands on a day when the location is closed
2119 return unless $copy and $circ->due_date;
2121 #my $org = (ref $copy->circ_lib) ? $copy->circ_lib->id : $copy->circ_lib;
2123 # due-date overlap should be determined by the location the item
2124 # is checked out from, not the owning or circ lib of the item
2125 my $org = $self->circ_lib;
2127 $logger->info("circulator: circ searching for closed date overlap on lib $org".
2128 " with an item due date of ".$circ->due_date );
2130 my $dateinfo = $U->storagereq(
2131 'open-ils.storage.actor.org_unit.closed_date.overlap',
2132 $org, $circ->due_date );
2135 $logger->info("circulator: $dateinfo : circ due data / close date overlap found : due_date=".
2136 $circ->due_date." start=". $dateinfo->{start}.", end=".$dateinfo->{end});
2138 # XXX make the behavior more dynamic
2139 # for now, we just push the due date to after the close date
2140 if ($shift_earlier) {
2141 $circ->due_date($dateinfo->{start});
2143 $circ->due_date($dateinfo->{end});
2151 sub create_due_date {
2152 my( $self, $duration, $date_ceiling, $force_date ) = @_;
2154 # if there is a raw time component (e.g. from postgres),
2155 # turn it into an interval that interval_to_seconds can parse
2156 $duration =~ s/(\d{2}):(\d{2}):(\d{2})/$1 h $2 m $3 s/o;
2158 # for now, use the server timezone. TODO: use workstation org timezone
2159 my $due_date = DateTime->now(time_zone => 'local');
2161 # add the circ duration
2162 $due_date->add(seconds => OpenSRF::Utils->interval_to_seconds($duration));
2165 my $cdate = DateTime::Format::ISO8601->new->parse_datetime(cleanse_ISO8601($date_ceiling));
2166 if ($cdate > DateTime->now and ($cdate < $due_date or $U->is_true( $force_date ))) {
2167 $logger->info("circulator: overriding due date with date ceiling: $date_ceiling");
2172 # return ISO8601 time with timezone
2173 return $due_date->strftime('%FT%T%z');
2178 sub make_precat_copy {
2180 my $copy = $self->copy;
2183 $logger->debug("circulator: Pre-cat copy already exists in checkout: ID=" . $copy->id);
2185 $copy->editor($self->editor->requestor->id);
2186 $copy->edit_date('now');
2187 $copy->dummy_title($self->dummy_title || $copy->dummy_title || '');
2188 $copy->dummy_isbn($self->dummy_isbn || $copy->dummy_isbn || '');
2189 $copy->dummy_author($self->dummy_author || $copy->dummy_author || '');
2190 $copy->circ_modifier($self->circ_modifier || $copy->circ_modifier);
2191 $self->update_copy();
2195 $logger->info("circulator: Creating a new precataloged ".
2196 "copy in checkout with barcode " . $self->copy_barcode);
2198 $copy = Fieldmapper::asset::copy->new;
2199 $copy->circ_lib($self->circ_lib);
2200 $copy->creator($self->editor->requestor->id);
2201 $copy->editor($self->editor->requestor->id);
2202 $copy->barcode($self->copy_barcode);
2203 $copy->call_number(OILS_PRECAT_CALL_NUMBER);
2204 $copy->loan_duration(OILS_PRECAT_COPY_LOAN_DURATION);
2205 $copy->fine_level(OILS_PRECAT_COPY_FINE_LEVEL);
2207 $copy->dummy_title($self->dummy_title || "");
2208 $copy->dummy_author($self->dummy_author || "");
2209 $copy->dummy_isbn($self->dummy_isbn || "");
2210 $copy->circ_modifier($self->circ_modifier);
2213 # See if we need to override the circ_lib for the copy with a configured circ_lib
2214 # Setting is shortname of the org unit
2215 my $precat_circ_lib = $U->ou_ancestor_setting_value(
2216 $self->circ_lib, 'circ.pre_cat_copy_circ_lib', $self->editor);
2218 if($precat_circ_lib) {
2219 my $org = $self->editor->search_actor_org_unit({shortname => $precat_circ_lib})->[0];
2222 $self->bail_on_events($self->editor->event);
2226 $copy->circ_lib($org->id);
2230 unless( $self->copy($self->editor->create_asset_copy($copy)) ) {
2232 $self->push_events($self->editor->event);
2236 # this is a little bit of a hack, but we need to
2237 # get the copy into the script runner
2238 $self->script_runner->insert("environment.copy", $copy, 1) if $self->script_runner;
2242 sub checkout_noncat {
2248 my $lib = $self->noncat_circ_lib || $self->circ_lib;
2249 my $count = $self->noncat_count || 1;
2250 my $cotime = cleanse_ISO8601($self->checkout_time) || "";
2252 $logger->info("circulator: circ creating $count noncat circs with checkout time $cotime");
2256 ( $circ, $evt ) = OpenILS::Application::Circ::NonCat::create_non_cat_circ(
2257 $self->editor->requestor->id,
2265 $self->push_events($evt);
2273 # If a copy goes into transit and is then checked in before the transit checkin
2274 # interval has expired, push an event onto the overridable events list.
2275 sub check_transit_checkin_interval {
2278 # only concerned with in-transit items
2279 return unless $U->copy_status($self->copy->status)->id == OILS_COPY_STATUS_IN_TRANSIT;
2281 # no interval, no problem
2282 my $interval = $U->ou_ancestor_setting_value($self->circ_lib, 'circ.transit.min_checkin_interval');
2283 return unless $interval;
2285 # capture the transit so we don't have to fetch it again later during checkin
2287 $self->editor->search_action_transit_copy(
2288 {target_copy => $self->copy->id, dest_recv_time => undef}
2292 # transit from X to X for whatever reason has no min interval
2293 return if $self->transit->source == $self->transit->dest;
2295 my $seconds = OpenSRF::Utils->interval_to_seconds($interval);
2296 my $t_start = DateTime::Format::ISO8601->new->parse_datetime(cleanse_ISO8601($self->transit->source_send_time));
2297 my $horizon = $t_start->add(seconds => $seconds);
2299 # See if we are still within the transit checkin forbidden range
2300 $self->push_events(OpenILS::Event->new('TRANSIT_CHECKIN_INTERVAL_BLOCK'))
2301 if $horizon > DateTime->now;
2304 # Retarget local holds at checkin
2305 sub checkin_retarget {
2307 return unless $self->retarget_mode =~ m/retarget/; # Retargeting?
2308 return unless $self->is_checkin; # Renewals need not be checked
2309 return if $self->capture eq 'nocapture'; # Not capturing holds anyway? Move on.
2310 return if $self->is_precat; # No holds for precats
2311 return unless $self->circ_lib == $self->copy->circ_lib; # Item isn't "home"? Don't check.
2312 return unless $U->is_true($self->copy->holdable); # Not holdable, shouldn't capture holds.
2313 my $status = $U->copy_status($self->copy->status);
2314 return unless $U->is_true($status->holdable); # Current status not holdable means no hold will ever target the item
2315 # Specifically target items that are likely new (by status ID)
2316 return unless $status->id == OILS_COPY_STATUS_IN_PROCESS || $self->retarget_mode =~ m/\.all/;
2317 my $location = $self->copy->location;
2318 if(!ref($location)) {
2319 $location = $self->editor->retrieve_asset_copy_location($self->copy->location);
2320 $self->copy->location($location);
2322 return unless $U->is_true($location->holdable); # Don't bother on non-holdable locations
2324 # Fetch holds for the bib
2325 my ($result) = $holdcode->method_lookup('open-ils.circ.holds.retrieve_all_from_title')->run(
2326 $self->editor->authtoken,
2329 capture_time => undef, # No touching captured holds
2330 frozen => 'f', # Don't bother with frozen holds
2331 pickup_lib => $self->circ_lib # Only holds actually here
2334 # Error? Skip the step.
2335 return if exists $result->{"ilsevent"};
2339 foreach my $holdlist (keys %{$result}) {
2340 push @$holds, @{$result->{$holdlist}};
2343 return if scalar(@$holds) == 0; # No holds, no retargeting
2345 # Loop over holds in request-ish order
2346 # Stage 1: Get them into request-ish order
2347 # Also grab type and target for skipping low hanging ones
2348 $result = $self->editor->json_query({
2349 "select" => { "ahr" => ["id", "hold_type", "target"] },
2350 "from" => { "ahr" => { "au" => { "fkey" => "usr", "join" => "pgt"} } },
2351 "where" => { "id" => $holds },
2353 { "class" => "pgt", "field" => "hold_priority"},
2354 { "class" => "ahr", "field" => "cut_in_line", "direction" => "desc", "transform" => "coalesce", "params" => ['f']},
2355 { "class" => "ahr", "field" => "selection_depth", "direction" => "desc"},
2356 { "class" => "ahr", "field" => "request_time"}
2361 if (ref $result eq "ARRAY" and scalar @$result) {
2362 foreach (@{$result}) {
2363 # Copy level, but not this copy?
2364 next if ($_->{hold_type} eq 'C' or $_->{hold_type} eq 'R' or $_->{hold_type} eq 'F'
2365 and $_->{target} != $self->copy->id);
2366 # Volume level, but not this volume?
2367 next if ($_->{hold_type} eq 'V' and $_->{target} != $self->volume->id);
2368 # So much for easy stuff, attempt a retarget!
2369 my $tresult = $U->storagereq('open-ils.storage.action.hold_request.copy_targeter', undef, $_->{id}, $self->copy->id);
2370 if(ref $tresult eq "ARRAY" and scalar @$tresult) {
2371 last if(exists $tresult->[0]->{found_copy} and $tresult->[0]->{found_copy});
2379 $self->log_me("do_checkin()");
2381 return $self->bail_on_events(
2382 OpenILS::Event->new('ASSET_COPY_NOT_FOUND'))
2385 $self->check_transit_checkin_interval;
2386 $self->checkin_retarget;
2388 # the renew code and mk_env should have already found our circulation object
2389 unless( $self->circ ) {
2391 my $circs = $self->editor->search_action_circulation(
2392 { target_copy => $self->copy->id, checkin_time => undef });
2394 $self->circ($$circs[0]);
2396 # for now, just warn if there are multiple open circs on a copy
2397 $logger->warn("circulator: we have ".scalar(@$circs).
2398 " open circs for copy " .$self->copy->id."!!") if @$circs > 1;
2401 # run the fine generator against this circ, if this circ is there
2402 $self->generate_fines_start if $self->circ;
2404 if( $self->checkin_check_holds_shelf() ) {
2405 $self->bail_on_events(OpenILS::Event->new('NO_CHANGE'));
2406 $self->hold($U->fetch_open_hold_by_copy($self->copy->id));
2407 $self->checkin_flesh_events;
2411 unless( $self->is_renewal ) {
2412 return $self->bail_on_events($self->editor->event)
2413 unless $self->editor->allowed('COPY_CHECKIN');
2416 $self->push_events($self->check_copy_alert());
2417 $self->push_events($self->check_checkin_copy_status());
2419 # if the circ is marked as 'claims returned', add the event to the list
2420 $self->push_events(OpenILS::Event->new('CIRC_CLAIMS_RETURNED'))
2421 if ($self->circ and $self->circ->stop_fines
2422 and $self->circ->stop_fines eq OILS_STOP_FINES_CLAIMSRETURNED);
2424 $self->check_circ_deposit();
2426 # handle the overridable events
2427 $self->override_events unless $self->is_renewal;
2428 return if $self->bail_out;
2430 if( $self->copy and !$self->transit ) {
2432 $self->editor->search_action_transit_copy(
2433 { target_copy => $self->copy->id, dest_recv_time => undef }
2439 $self->generate_fines_finish;
2440 $self->checkin_handle_circ;
2441 return if $self->bail_out;
2442 $self->checkin_changed(1);
2444 } elsif( $self->transit ) {
2445 my $hold_transit = $self->process_received_transit;
2446 $self->checkin_changed(1);
2448 if( $self->bail_out ) {
2449 $self->checkin_flesh_events;
2453 if( my $e = $self->check_checkin_copy_status() ) {
2454 # If the original copy status is special, alert the caller
2455 my $ev = $self->events;
2456 $self->events([$e]);
2457 $self->override_events;
2458 return if $self->bail_out;
2462 if( $hold_transit or
2463 $U->copy_status($self->copy->status)->id
2464 == OILS_COPY_STATUS_ON_HOLDS_SHELF ) {
2467 if( $hold_transit ) {
2468 $hold = $self->editor->retrieve_action_hold_request($hold_transit->hold);
2470 ($hold) = $U->fetch_open_hold_by_copy($self->copy->id);
2475 if( $hold and $hold->cancel_time ) { # this transited hold was cancelled mid-transit
2477 $logger->info("circulator: we received a transit on a cancelled hold " . $hold->id);
2478 $self->reshelve_copy(1);
2479 $self->cancelled_hold_transit(1);
2480 $self->notify_hold(0); # don't notify for cancelled holds
2481 return if $self->bail_out;
2485 # hold transited to correct location
2486 $self->checkin_flesh_events;
2491 } elsif( $U->copy_status($self->copy->status)->id == OILS_COPY_STATUS_IN_TRANSIT ) {
2493 $logger->warn("circulator: we have a copy ".$self->copy->barcode.
2494 " that is in-transit, but there is no transit.. repairing");
2495 $self->reshelve_copy(1);
2496 return if $self->bail_out;
2499 if( $self->is_renewal ) {
2500 $self->finish_fines_and_voiding;
2501 return if $self->bail_out;
2502 $self->push_events(OpenILS::Event->new('SUCCESS'));
2506 # ------------------------------------------------------------------------------
2507 # Circulations and transits are now closed where necessary. Now go on to see if
2508 # this copy can fulfill a hold or needs to be routed to a different location
2509 # ------------------------------------------------------------------------------
2511 my $needed_for_something = 0; # formerly "needed_for_hold"
2513 if(!$self->noop) { # /not/ a no-op checkin, capture for hold or put item into transit
2515 if (!$self->remote_hold) {
2516 if ($self->use_booking) {
2517 my $potential_hold = $self->hold_capture_is_possible;
2518 my $potential_reservation = $self->reservation_capture_is_possible;
2520 if ($potential_hold and $potential_reservation) {
2521 $logger->info("circulator: item could fulfill either hold or reservation");
2522 $self->push_events(new OpenILS::Event(
2523 "HOLD_RESERVATION_CONFLICT",
2524 "hold" => $potential_hold,
2525 "reservation" => $potential_reservation
2527 return if $self->bail_out;
2528 } elsif ($potential_hold) {
2529 $needed_for_something =
2530 $self->attempt_checkin_hold_capture;
2531 } elsif ($potential_reservation) {
2532 $needed_for_something =
2533 $self->attempt_checkin_reservation_capture;
2536 $needed_for_something = $self->attempt_checkin_hold_capture;
2539 return if $self->bail_out;
2541 unless($needed_for_something) {
2542 my $circ_lib = (ref $self->copy->circ_lib) ?
2543 $self->copy->circ_lib->id : $self->copy->circ_lib;
2545 if( $self->remote_hold ) {
2546 $circ_lib = $self->remote_hold->pickup_lib;
2547 $logger->warn("circulator: Copy ".$self->copy->barcode.
2548 " is on a remote hold's shelf, sending to $circ_lib");
2551 $logger->debug("circulator: circlib=$circ_lib, workstation=".$self->circ_lib);
2553 if( $circ_lib == $self->circ_lib and not ($self->hold_as_transit and $self->remote_hold) ) {
2554 # copy is where it needs to be, either for hold or reshelving
2556 $self->checkin_handle_precat();
2557 return if $self->bail_out;
2560 # copy needs to transit "home", or stick here if it's a floating copy
2562 if ($U->is_true( $self->copy->floating ) && !$self->remote_hold) { # copy is floating, stick here
2563 $self->checkin_changed(1);
2564 $self->copy->circ_lib( $self->circ_lib );
2567 my $bc = $self->copy->barcode;
2568 $logger->info("circulator: copy $bc at the wrong location, sending to $circ_lib");
2569 $self->checkin_build_copy_transit($circ_lib);
2570 return if $self->bail_out;
2571 $self->push_events(OpenILS::Event->new('ROUTE_ITEM', org => $circ_lib));
2575 } else { # no-op checkin
2576 if ($U->is_true( $self->copy->floating )) { # XXX floating items still stick where they are even with no-op checkin?
2577 $self->checkin_changed(1);
2578 $self->copy->circ_lib( $self->circ_lib );
2583 if($self->claims_never_checked_out and
2584 $U->ou_ancestor_setting_value($self->circ->circ_lib, 'circ.claim_never_checked_out.mark_missing')) {
2586 # the item was not supposed to be checked out to the user and should now be marked as missing
2587 $self->copy->status(OILS_COPY_STATUS_MISSING);
2591 $self->reshelve_copy unless $needed_for_something;
2594 return if $self->bail_out;
2596 unless($self->checkin_changed) {
2598 $self->push_events(OpenILS::Event->new('NO_CHANGE'));
2599 my $stat = $U->copy_status($self->copy->status)->id;
2601 $self->hold($U->fetch_open_hold_by_copy($self->copy->id))
2602 if( $stat == OILS_COPY_STATUS_ON_HOLDS_SHELF );
2603 $self->bail_out(1); # no need to commit anything
2607 $self->push_events(OpenILS::Event->new('SUCCESS'))
2608 unless @{$self->events};
2611 $self->finish_fines_and_voiding;
2613 OpenILS::Utils::Penalty->calculate_penalties(
2614 $self->editor, $self->patron->id, $self->circ_lib) if $self->patron;
2616 $self->checkin_flesh_events;
2620 sub finish_fines_and_voiding {
2622 return unless $self->circ;
2624 # gather any updates to the circ after fine generation, if there was a circ
2625 $self->generate_fines_finish;
2627 return unless $self->backdate or $self->void_overdues;
2629 # void overdues after fine generation to prevent concurrent DB access to overdue billings
2630 my $note = 'System: Amnesty Checkin' if $self->void_overdues;
2632 my $evt = OpenILS::Application::Circ::CircCommon->void_overdues(
2633 $self->editor, $self->circ, $self->backdate, $note);
2635 return $self->bail_on_events($evt) if $evt;
2637 # make sure the circ isn't closed if we just voided some fines
2638 $evt = OpenILS::Application::Circ::CircCommon->reopen_xact($self->editor, $self->circ->id);
2639 return $self->bail_on_events($evt) if $evt;
2645 # if a deposit was payed for this item, push the event
2646 sub check_circ_deposit {
2648 return unless $self->circ;
2649 my $deposit = $self->editor->search_money_billing(
2651 xact => $self->circ->id,
2653 }, {idlist => 1})->[0];
2655 $self->push_events(OpenILS::Event->new(
2656 'ITEM_DEPOSIT_PAID', payload => $deposit)) if $deposit;
2661 my $force = $self->force || shift;
2662 my $copy = $self->copy;
2664 my $stat = $U->copy_status($copy->status)->id;
2667 $stat != OILS_COPY_STATUS_ON_HOLDS_SHELF and
2668 $stat != OILS_COPY_STATUS_CATALOGING and
2669 $stat != OILS_COPY_STATUS_IN_TRANSIT and
2670 $stat != OILS_COPY_STATUS_RESHELVING )) {
2672 $copy->status( OILS_COPY_STATUS_RESHELVING );
2674 $self->checkin_changed(1);
2679 # Returns true if the item is at the current location
2680 # because it was transited there for a hold and the
2681 # hold has not been fulfilled
2682 sub checkin_check_holds_shelf {
2684 return 0 unless $self->copy;
2687 $U->copy_status($self->copy->status)->id ==
2688 OILS_COPY_STATUS_ON_HOLDS_SHELF;
2690 # Attempt to clear shelf expired holds for this copy
2691 $holdcode->method_lookup('open-ils.circ.hold.clear_shelf.process')->run($self->editor->authtoken, $self->circ_lib, $self->copy->id)
2692 if($self->clear_expired);
2694 # find the hold that put us on the holds shelf
2695 my $holds = $self->editor->search_action_hold_request(
2697 current_copy => $self->copy->id,
2698 capture_time => { '!=' => undef },
2699 fulfillment_time => undef,
2700 cancel_time => undef,
2705 $logger->warn("circulator: copy is on-holds-shelf, but there is no hold - reshelving");
2706 $self->reshelve_copy(1);
2710 my $hold = $$holds[0];
2712 $logger->info("circulator: we found a captured, un-fulfilled hold [".
2713 $hold->id. "] for copy ".$self->copy->barcode);
2715 if( $hold->pickup_lib == $self->circ_lib and not $self->hold_as_transit ) {
2716 $logger->info("circulator: hold is for here .. we're done: ".$self->copy->barcode);
2720 $logger->info("circulator: hold is not for here..");
2721 $self->remote_hold($hold);
2726 sub checkin_handle_precat {
2728 my $copy = $self->copy;
2730 if( $self->is_precat and ($copy->status != OILS_COPY_STATUS_CATALOGING) ) {
2731 $copy->status(OILS_COPY_STATUS_CATALOGING);
2732 $self->update_copy();
2733 $self->checkin_changed(1);
2734 $self->push_events(OpenILS::Event->new('ITEM_NOT_CATALOGED'));
2739 sub checkin_build_copy_transit {
2742 my $copy = $self->copy;
2743 my $transit = Fieldmapper::action::transit_copy->new;
2745 #$dest ||= (ref($copy->circ_lib)) ? $copy->circ_lib->id : $copy->circ_lib;
2746 $logger->info("circulator: transiting copy to $dest");
2748 $transit->source($self->circ_lib);
2749 $transit->dest($dest);
2750 $transit->target_copy($copy->id);
2751 $transit->source_send_time('now');
2752 $transit->copy_status( $U->copy_status($copy->status)->id );
2754 $logger->debug("circulator: setting copy status on transit: ".$transit->copy_status);
2756 return $self->bail_on_events($self->editor->event)
2757 unless $self->editor->create_action_transit_copy($transit);
2759 $copy->status(OILS_COPY_STATUS_IN_TRANSIT);
2761 $self->checkin_changed(1);
2765 sub hold_capture_is_possible {
2767 my $copy = $self->copy;
2769 # we've been explicitly told not to capture any holds
2770 return 0 if $self->capture eq 'nocapture';
2772 # See if this copy can fulfill any holds
2773 my $hold = $holdcode->find_nearest_permitted_hold(
2774 $self->editor, $copy, $self->editor->requestor, 1 # check_only
2776 return undef if ref $hold eq "HASH" and
2777 $hold->{"textcode"} eq "ACTION_HOLD_REQUEST_NOT_FOUND";
2781 sub reservation_capture_is_possible {
2783 my $copy = $self->copy;
2785 # we've been explicitly told not to capture any holds
2786 return 0 if $self->capture eq 'nocapture';
2788 my $booking_ses = OpenSRF::AppSession->connect("open-ils.booking");
2789 my $resv = $booking_ses->request(
2790 "open-ils.booking.reservations.could_capture",
2791 $self->editor->authtoken, $copy->barcode
2793 $booking_ses->disconnect;
2794 if (ref($resv) eq "HASH" and exists $resv->{"textcode"}) {
2795 $self->push_events($resv);
2801 # returns true if the item was used (or may potentially be used
2802 # in subsequent calls) to capture a hold.
2803 sub attempt_checkin_hold_capture {
2805 my $copy = $self->copy;
2807 # we've been explicitly told not to capture any holds
2808 return 0 if $self->capture eq 'nocapture';
2810 # See if this copy can fulfill any holds
2811 my ($hold, undef, $retarget) = $holdcode->find_nearest_permitted_hold(
2812 $self->editor, $copy, $self->editor->requestor );
2815 $logger->debug("circulator: no potential permitted".
2816 "holds found for copy ".$copy->barcode);
2820 if($self->capture ne 'capture') {
2821 # see if this item is in a hold-capture-delay location
2822 my $location = $self->copy->location;
2823 if(!ref($location)) {
2824 $location = $self->editor->retrieve_asset_copy_location($self->copy->location);
2825 $self->copy->location($location);
2827 if($U->is_true($location->hold_verify)) {
2828 $self->bail_on_events(
2829 OpenILS::Event->new('HOLD_CAPTURE_DELAYED', copy_location => $location));
2834 $self->retarget($retarget);
2836 $logger->info("circulator: found permitted hold ".$hold->id." for copy, capturing...");
2838 $hold->current_copy($copy->id);
2839 $hold->capture_time('now');
2840 $self->put_hold_on_shelf($hold)
2841 if $hold->pickup_lib == $self->circ_lib;
2843 # prevent DB errors caused by fetching
2844 # holds from storage, and updating through cstore
2845 $hold->clear_fulfillment_time;
2846 $hold->clear_fulfillment_staff;
2847 $hold->clear_fulfillment_lib;
2848 $hold->clear_expire_time;
2849 $hold->clear_cancel_time;
2850 $hold->clear_prev_check_time unless $hold->prev_check_time;
2852 $self->bail_on_events($self->editor->event)
2853 unless $self->editor->update_action_hold_request($hold);
2855 $self->checkin_changed(1);
2857 return 0 if $self->bail_out;
2859 if( $hold->pickup_lib == $self->circ_lib && not $self->hold_as_transit ) {
2861 # This hold was captured in the correct location
2862 $copy->status(OILS_COPY_STATUS_ON_HOLDS_SHELF);
2863 $self->push_events(OpenILS::Event->new('SUCCESS'));
2865 #$self->do_hold_notify($hold->id);
2866 $self->notify_hold($hold->id);
2870 # Hold needs to be picked up elsewhere. Build a hold
2871 # transit and route the item.
2872 $self->checkin_build_hold_transit();
2873 $copy->status(OILS_COPY_STATUS_IN_TRANSIT);
2874 return 0 if $self->bail_out;
2875 $self->push_events(OpenILS::Event->new('ROUTE_ITEM', org => $hold->pickup_lib));
2878 # make sure we save the copy status
2883 sub attempt_checkin_reservation_capture {
2885 my $copy = $self->copy;
2887 # we've been explicitly told not to capture any holds
2888 return 0 if $self->capture eq 'nocapture';
2890 my $booking_ses = OpenSRF::AppSession->connect("open-ils.booking");
2891 my $evt = $booking_ses->request(
2892 "open-ils.booking.resources.capture_for_reservation",
2893 $self->editor->authtoken,
2895 1 # don't update copy - we probably have it locked
2897 $booking_ses->disconnect;
2899 if (ref($evt) ne "HASH" or not exists $evt->{"textcode"}) {
2901 "open-ils.booking.resources.capture_for_reservation " .
2902 "didn't return an event!"
2906 $evt->{"textcode"} eq "RESERVATION_NOT_FOUND" and
2907 $evt->{"payload"}->{"fail_cause"} eq "not-transferable"
2909 # not-transferable is an error event we'll pass on the user
2910 $logger->warn("reservation capture attempted against non-transferable item");
2911 $self->push_events($evt);
2913 } elsif ($evt->{"textcode"} eq "SUCCESS") {
2914 # Re-retrieve copy as reservation capture may have changed
2915 # its status and whatnot.
2917 "circulator: booking capture win on copy " . $self->copy->id
2919 if (my $new_copy_status = $evt->{"payload"}->{"new_copy_status"}) {
2921 "circulator: changing copy " . $self->copy->id .
2922 "'s status from " . $self->copy->status . " to " .
2925 $self->copy->status($new_copy_status);
2928 $self->reservation($evt->{"payload"}->{"reservation"});
2930 if (exists $evt->{"payload"}->{"transit"}) {
2934 "org" => $evt->{"payload"}->{"transit"}->dest
2938 $self->checkin_changed(1);
2942 # other results are treated as "nothing to capture"
2946 sub do_hold_notify {
2947 my( $self, $holdid ) = @_;
2949 my $e = new_editor(xact => 1);
2950 my $hold = $e->retrieve_action_hold_request($holdid) or return $e->die_event;
2952 my $ses = OpenSRF::AppSession->create('open-ils.trigger');
2953 $ses->request('open-ils.trigger.event.autocreate', 'hold.available', $hold, $hold->pickup_lib);
2955 $logger->info("circulator: running delayed hold notify process");
2957 # my $notifier = OpenILS::Application::Circ::HoldNotify->new(
2958 # hold_id => $holdid, editor => new_editor(requestor=>$self->editor->requestor));
2960 my $notifier = OpenILS::Application::Circ::HoldNotify->new(
2961 hold_id => $holdid, requestor => $self->editor->requestor);
2963 $logger->debug("circulator: built hold notifier");
2965 if(!$notifier->event) {
2967 $logger->info("circulator: attempt at sending hold notification for hold $holdid");
2969 my $stat = $notifier->send_email_notify;
2970 if( $stat == '1' ) {
2971 $logger->info("circulator: hold notify succeeded for hold $holdid");
2975 $logger->debug("circulator: * hold notify cancelled or failed for hold $holdid");
2978 $logger->info("circulator: Not sending hold notification since the patron has no email address");
2982 sub retarget_holds {
2984 $logger->info("circulator: retargeting holds @{$self->retarget} after opportunistic capture");
2985 my $ses = OpenSRF::AppSession->create('open-ils.storage');
2986 $ses->request('open-ils.storage.action.hold_request.copy_targeter', undef, $self->retarget);
2987 # no reason to wait for the return value
2991 sub checkin_build_hold_transit {
2994 my $copy = $self->copy;
2995 my $hold = $self->hold;
2996 my $trans = Fieldmapper::action::hold_transit_copy->new;
2998 $logger->debug("circulator: building hold transit for ".$copy->barcode);
3000 $trans->hold($hold->id);
3001 $trans->source($self->circ_lib);
3002 $trans->dest($hold->pickup_lib);
3003 $trans->source_send_time("now");
3004 $trans->target_copy($copy->id);
3006 # when the copy gets to its destination, it will recover
3007 # this status - put it onto the holds shelf
3008 $trans->copy_status(OILS_COPY_STATUS_ON_HOLDS_SHELF);
3010 return $self->bail_on_events($self->editor->event)
3011 unless $self->editor->create_action_hold_transit_copy($trans);
3016 sub process_received_transit {
3018 my $copy = $self->copy;
3019 my $copyid = $self->copy->id;
3021 my $status_name = $U->copy_status($copy->status)->name;
3022 $logger->debug("circulator: attempting transit receive on ".
3023 "copy $copyid. Copy status is $status_name");
3025 my $transit = $self->transit;
3027 if( $transit->dest != $self->circ_lib or ($self->hold_as_transit && $transit->copy_status == OILS_COPY_STATUS_ON_HOLDS_SHELF) ) {
3028 # - this item is in-transit to a different location
3029 # - Or we are capturing holds as transits, so why create a new transit?
3031 my $tid = $transit->id;
3032 my $loc = $self->circ_lib;
3033 my $dest = $transit->dest;
3035 $logger->info("circulator: Fowarding transit on copy which is destined ".
3036 "for a different location. transit=$tid, copy=$copyid, current ".
3037 "location=$loc, destination location=$dest");
3039 my $evt = OpenILS::Event->new('ROUTE_ITEM', org => $dest, payload => {});
3041 # grab the associated hold object if available
3042 my $ht = $self->editor->retrieve_action_hold_transit_copy($tid);
3043 $self->hold($self->editor->retrieve_action_hold_request($ht->hold)) if $ht;
3045 return $self->bail_on_events($evt);
3048 # The transit is received, set the receive time
3049 $transit->dest_recv_time('now');
3050 $self->bail_on_events($self->editor->event)
3051 unless $self->editor->update_action_transit_copy($transit);
3053 my $hold_transit = $self->editor->retrieve_action_hold_transit_copy($transit->id);
3055 $logger->info("circulator: Recovering original copy status in transit: ".$transit->copy_status);
3056 $copy->status( $transit->copy_status );
3057 $self->update_copy();
3058 return if $self->bail_out;
3062 my $hold = $self->editor->retrieve_action_hold_request($hold_transit->hold);
3064 # hold has arrived at destination, set shelf time
3065 $self->put_hold_on_shelf($hold);
3066 $self->bail_on_events($self->editor->event)
3067 unless $self->editor->update_action_hold_request($hold);
3068 return if $self->bail_out;
3070 $self->notify_hold($hold_transit->hold);
3075 OpenILS::Event->new(
3078 payload => { transit => $transit, holdtransit => $hold_transit } ));
3080 return $hold_transit;
3084 # ------------------------------------------------------------------
3085 # Sets the shelf_time and shelf_expire_time for a newly shelved hold
3086 # ------------------------------------------------------------------
3087 sub put_hold_on_shelf {
3088 my($self, $hold) = @_;
3090 $hold->shelf_time('now');
3092 my $shelf_expire = $U->ou_ancestor_setting_value(
3093 $self->circ_lib, 'circ.holds.default_shelf_expire_interval', $self->editor);
3095 return undef unless $shelf_expire;
3097 my $seconds = OpenSRF::Utils->interval_to_seconds($shelf_expire);
3098 my $expire_time = DateTime->now->add(seconds => $seconds);
3100 # if the shelf expire time overlaps with a pickup lib's
3101 # closed date, push it out to the first open date
3102 my $dateinfo = $U->storagereq(
3103 'open-ils.storage.actor.org_unit.closed_date.overlap',
3104 $hold->pickup_lib, $expire_time);
3107 my $dt_parser = DateTime::Format::ISO8601->new;
3108 $expire_time = $dt_parser->parse_datetime(cleanse_ISO8601($dateinfo->{end}));
3110 # TODO: enable/disable time bump via setting?
3111 $expire_time->set(hour => '23', minute => '59', second => '59');
3113 $logger->info("circulator: shelf_expire_time overlaps".
3114 " with closed date, pushing expire time to $expire_time");
3117 $hold->shelf_expire_time($expire_time->strftime('%FT%T%z'));
3123 sub generate_fines {
3125 my $reservation = shift;
3127 $self->generate_fines_start($reservation);
3128 $self->generate_fines_finish($reservation);
3133 sub generate_fines_start {
3135 my $reservation = shift;
3136 my $dt_parser = DateTime::Format::ISO8601->new;
3138 my $obj = $reservation ? $self->reservation : $self->circ;
3140 # If we have a grace period
3141 if($obj->can('grace_period')) {
3142 # Parse out the due date
3143 my $due_date = $dt_parser->parse_datetime( cleanse_ISO8601($obj->due_date) );
3144 # Add the grace period to the due date
3145 $due_date->add(seconds => OpenSRF::Utils->interval_to_seconds($obj->grace_period));
3146 # Don't generate fines on circs still in grace period
3147 return undef if ($due_date > DateTime->now);
3150 if (!exists($self->{_gen_fines_req})) {
3151 $self->{_gen_fines_req} = OpenSRF::AppSession->create('open-ils.storage')
3153 'open-ils.storage.action.circulation.overdue.generate_fines',
3161 sub generate_fines_finish {
3163 my $reservation = shift;
3165 return undef unless $self->{_gen_fines_req};
3167 my $id = $reservation ? $self->reservation->id : $self->circ->id;
3169 $self->{_gen_fines_req}->wait_complete;
3170 delete($self->{_gen_fines_req});
3172 # refresh the circ in case the fine generator set the stop_fines field
3173 $self->reservation($self->editor->retrieve_booking_reservation($id)) if $reservation;
3174 $self->circ($self->editor->retrieve_action_circulation($id)) if !$reservation;
3179 sub checkin_handle_circ {
3181 my $circ = $self->circ;
3182 my $copy = $self->copy;
3186 $self->backdate($circ->xact_start) if $self->claims_never_checked_out;
3188 # backdate the circ if necessary
3189 if($self->backdate) {
3190 my $evt = $self->checkin_handle_backdate;
3191 return $self->bail_on_events($evt) if $evt;
3194 if(!$circ->stop_fines) {
3195 $circ->stop_fines(OILS_STOP_FINES_CHECKIN);
3196 $circ->stop_fines(OILS_STOP_FINES_RENEW) if $self->is_renewal;
3197 $circ->stop_fines(OILS_STOP_FINES_CLAIMS_NEVERCHECKEDOUT) if $self->claims_never_checked_out;
3198 $circ->stop_fines_time('now');
3199 $circ->stop_fines_time($self->backdate) if $self->backdate;
3202 # Set the checkin vars since we have the item
3203 $circ->checkin_time( ($self->backdate) ? $self->backdate : 'now' );
3205 # capture the true scan time for back-dated checkins
3206 $circ->checkin_scan_time('now');
3208 $circ->checkin_staff($self->editor->requestor->id);
3209 $circ->checkin_lib($self->circ_lib);
3210 $circ->checkin_workstation($self->editor->requestor->wsid);
3212 my $circ_lib = (ref $self->copy->circ_lib) ?
3213 $self->copy->circ_lib->id : $self->copy->circ_lib;
3214 my $stat = $U->copy_status($self->copy->status)->id;
3216 # immediately available keeps items lost or missing items from going home before being handled
3217 my $lost_immediately_available = $U->ou_ancestor_setting_value(
3218 $circ_lib, OILS_SETTING_LOST_IMMEDIATELY_AVAILABLE, $self->editor) || 0;
3221 if ( (!$lost_immediately_available) && ($circ_lib != $self->circ_lib) ) {
3223 if( ($stat == OILS_COPY_STATUS_LOST or $stat == OILS_COPY_STATUS_MISSING) ) {
3224 $logger->info("circulator: not updating copy status on checkin because copy is lost/missing");
3226 $self->copy->status($U->copy_status(OILS_COPY_STATUS_RESHELVING));
3230 } elsif ($stat == OILS_COPY_STATUS_LOST) {
3232 $self->checkin_handle_lost($circ_lib);
3236 $self->copy->status($U->copy_status(OILS_COPY_STATUS_RESHELVING));
3241 # see if there are any fines owed on this circ. if not, close it
3242 ($obt) = $U->fetch_mbts($circ->id, $self->editor);
3243 $circ->xact_finish('now') if( $obt and $obt->balance_owed == 0 );
3245 $logger->debug("circulator: ".$obt->balance_owed." is owed on this circulation");
3247 return $self->bail_on_events($self->editor->event)
3248 unless $self->editor->update_action_circulation($circ);
3254 # ------------------------------------------------------------------
3255 # See if we need to void billings for lost checkin
3256 # ------------------------------------------------------------------
3257 sub checkin_handle_lost {
3259 my $circ_lib = shift;
3260 my $circ = $self->circ;
3262 my $max_return = $U->ou_ancestor_setting_value(
3263 $circ_lib, OILS_SETTING_MAX_ACCEPT_RETURN_OF_LOST, $self->editor) || 0;
3268 my @tm = reverse($circ->due_date =~ /([\d\.]+)/og);
3269 $tm[5] -= 1 if $tm[5] > 0;
3270 my $due = timelocal(int($tm[1]), int($tm[2]), int($tm[3]), int($tm[4]), int($tm[5]), int($tm[6]));
3272 my $last_chance = OpenSRF::Utils->interval_to_seconds($max_return) + int($due);
3273 $logger->info("MAX OD: ".$max_return." DUEDATE: ".$circ->due_date." TODAY: ".$today." DUE: ".$due." LAST: ".$last_chance);
3275 $max_return = 0 if $today < $last_chance;
3278 if (!$max_return){ # there's either no max time to accept returns defined or we're within that time
3280 my $void_lost = $U->ou_ancestor_setting_value(
3281 $circ_lib, OILS_SETTING_VOID_LOST_ON_CHECKIN, $self->editor) || 0;
3282 my $void_lost_fee = $U->ou_ancestor_setting_value(
3283 $circ_lib, OILS_SETTING_VOID_LOST_PROCESS_FEE_ON_CHECKIN, $self->editor) || 0;
3284 my $restore_od = $U->ou_ancestor_setting_value(
3285 $circ_lib, OILS_SETTING_RESTORE_OVERDUE_ON_LOST_RETURN, $self->editor) || 0;
3286 $self->generate_lost_overdue(1) if $U->ou_ancestor_setting_value(
3287 $circ_lib, OILS_SETTING_GENERATE_OVERDUE_ON_LOST_RETURN, $self->editor);
3289 $self->checkin_handle_lost_now_found(3) if $void_lost;
3290 $self->checkin_handle_lost_now_found(4) if $void_lost_fee;
3291 $self->checkin_handle_lost_now_found_restore_od($circ_lib) if $restore_od && ! $self->void_overdues;
3294 $self->copy->status($U->copy_status(OILS_COPY_STATUS_RESHELVING));
3299 sub checkin_handle_backdate {
3302 # ------------------------------------------------------------------
3303 # clean up the backdate for date comparison
3304 # XXX We are currently taking the due-time from the original due-date,
3305 # not the input. Do we need to do this? This certainly interferes with
3306 # backdating of hourly checkouts, but that is likely a very rare case.
3307 # ------------------------------------------------------------------
3308 my $bd = cleanse_ISO8601($self->backdate);
3309 my $original_date = DateTime::Format::ISO8601->new->parse_datetime(cleanse_ISO8601($self->circ->due_date));
3310 my $new_date = DateTime::Format::ISO8601->new->parse_datetime($bd);
3311 $bd = cleanse_ISO8601($new_date->ymd . 'T' . $original_date->strftime('%T%z'));
3313 $self->backdate($bd);
3318 sub check_checkin_copy_status {
3320 my $copy = $self->copy;
3322 my $status = $U->copy_status($copy->status)->id;
3325 if( $status == OILS_COPY_STATUS_AVAILABLE ||
3326 $status == OILS_COPY_STATUS_CHECKED_OUT ||
3327 $status == OILS_COPY_STATUS_IN_PROCESS ||
3328 $status == OILS_COPY_STATUS_ON_HOLDS_SHELF ||
3329 $status == OILS_COPY_STATUS_IN_TRANSIT ||
3330 $status == OILS_COPY_STATUS_CATALOGING ||
3331 $status == OILS_COPY_STATUS_ON_RESV_SHELF ||
3332 $status == OILS_COPY_STATUS_RESHELVING );
3334 return OpenILS::Event->new('COPY_STATUS_LOST', payload => $copy )
3335 if( $status == OILS_COPY_STATUS_LOST );
3337 return OpenILS::Event->new('COPY_STATUS_MISSING', payload => $copy )
3338 if( $status == OILS_COPY_STATUS_MISSING );
3340 return OpenILS::Event->new('COPY_BAD_STATUS', payload => $copy );
3345 # --------------------------------------------------------------------------
3346 # On checkin, we need to return as many relevant objects as we can
3347 # --------------------------------------------------------------------------
3348 sub checkin_flesh_events {
3351 if( grep { $_->{textcode} eq 'SUCCESS' } @{$self->events}
3352 and grep { $_->{textcode} eq 'ITEM_NOT_CATALOGED' } @{$self->events} ) {
3353 $self->events([grep { $_->{textcode} eq 'ITEM_NOT_CATALOGED' } @{$self->events}]);
3356 my $record = $U->record_to_mvr($self->title) if($self->title and !$self->is_precat);
3359 if($self->hold and !$self->hold->cancel_time) {
3360 $hold = $self->hold;
3361 $hold->notes($self->editor->search_action_hold_request_note({hold => $hold->id}));
3365 # if we checked in a circulation, flesh the billing summary data
3366 $self->circ->billable_transaction(
3367 $self->editor->retrieve_money_billable_transaction([
3369 {flesh => 1, flesh_fields => {mbt => ['summary']}}
3375 # flesh some patron fields before returning
3377 $self->editor->retrieve_actor_user([
3382 au => ['card', 'billing_address', 'mailing_address']
3389 for my $evt (@{$self->events}) {
3392 $payload->{copy} = $U->unflesh_copy($self->copy);
3393 $payload->{volume} = $self->volume;
3394 $payload->{record} = $record,
3395 $payload->{circ} = $self->circ;
3396 $payload->{transit} = $self->transit;
3397 $payload->{cancelled_hold_transit} = 1 if $self->cancelled_hold_transit;
3398 $payload->{hold} = $hold;
3399 $payload->{patron} = $self->patron;
3400 $payload->{reservation} = $self->reservation
3401 unless (not $self->reservation or $self->reservation->cancel_time);
3403 $evt->{payload} = $payload;
3408 my( $self, $msg ) = @_;
3409 my $bc = ($self->copy) ? $self->copy->barcode :
3412 my $usr = ($self->patron) ? $self->patron->id : "";
3413 $logger->info("circulator: $msg requestor=".$self->editor->requestor->id.
3414 ", recipient=$usr, copy=$bc");
3420 $self->log_me("do_renew()");
3422 # Make sure there is an open circ to renew that is not
3423 # marked as LOST, CLAIMSRETURNED, or LONGOVERDUE
3424 my $usrid = $self->patron->id if $self->patron;
3425 my $circ = $self->editor->search_action_circulation({
3426 target_copy => $self->copy->id,
3427 xact_finish => undef,
3428 checkin_time => undef,
3429 ($usrid ? (usr => $usrid) : ()),
3431 {stop_fines => undef},
3432 {stop_fines => OILS_STOP_FINES_MAX_FINES}
3436 return $self->bail_on_events($self->editor->event) unless $circ;
3438 # A user is not allowed to renew another user's items without permission
3439 unless( $circ->usr eq $self->editor->requestor->id ) {
3440 return $self->bail_on_events($self->editor->events)
3441 unless $self->editor->allowed('RENEW_CIRC', $circ->circ_lib);
3444 $self->push_events(OpenILS::Event->new('MAX_RENEWALS_REACHED'))
3445 if $circ->renewal_remaining < 1;
3447 # -----------------------------------------------------------------
3449 $self->parent_circ($circ->id);
3450 $self->renewal_remaining( $circ->renewal_remaining - 1 );
3453 # Opac renewal - re-use circ library from original circ (unless told not to)
3454 if($self->opac_renewal) {
3455 unless(defined($opac_renewal_use_circ_lib)) {
3456 my $use_circ_lib = $self->editor->retrieve_config_global_flag('circ.opac_renewal.use_original_circ_lib');
3457 if($use_circ_lib and $U->is_true($use_circ_lib->enabled)) {
3458 $opac_renewal_use_circ_lib = 1;
3461 $opac_renewal_use_circ_lib = 0;
3464 $self->circ_lib($circ->circ_lib) if($opac_renewal_use_circ_lib);
3467 # Run the fine generator against the old circ
3468 $self->generate_fines_start;
3470 $self->run_renew_permit;
3473 $self->do_checkin();
3474 return if $self->bail_out;
3476 unless( $self->permit_override ) {
3478 return if $self->bail_out;
3479 $self->is_precat(1) if $self->have_event('ITEM_NOT_CATALOGED');
3480 $self->remove_event('ITEM_NOT_CATALOGED');
3483 $self->override_events;
3484 return if $self->bail_out;
3487 $self->do_checkout();
3492 my( $self, $evt ) = @_;
3493 $evt = (ref $evt) ? $evt->{textcode} : $evt;
3494 $logger->debug("circulator: removing event from list: $evt");
3495 my @events = @{$self->events};
3496 $self->events( [ grep { $_->{textcode} ne $evt } @events ] );
3501 my( $self, $evt ) = @_;
3502 $evt = (ref $evt) ? $evt->{textcode} : $evt;
3503 return grep { $_->{textcode} eq $evt } @{$self->events};
3508 sub run_renew_permit {
3511 if ($U->ou_ancestor_setting_value($self->circ_lib, 'circ.block_renews_for_holds')) {
3512 my ($hold, undef, $retarget) = $holdcode->find_nearest_permitted_hold(
3513 $self->editor, $self->copy, $self->editor->requestor, 1
3515 $self->push_events(new OpenILS::Event("COPY_NEEDED_FOR_HOLD")) if $hold;
3518 if(!$self->legacy_script_support) {
3519 my $results = $self->run_indb_circ_test;
3520 $self->push_events($self->matrix_test_result_events)
3521 unless $self->circ_test_success;
3524 my $runner = $self->script_runner;
3526 $runner->load($self->circ_permit_renew);
3527 my $result = $runner->run or
3528 throw OpenSRF::EX::ERROR ("Circ Permit Renew Script Died: $@");
3529 if ($result->{"events"}) {
3531 map { new OpenILS::Event($_) } @{$result->{"events"}}
3534 "circulator: circ_permit_renew for user " .
3535 $self->patron->id . " returned " .
3536 scalar(@{$result->{"events"}}) . " event(s)"
3540 $self->mk_script_runner;
3543 $logger->debug("circulator: re-creating script runner to be safe");
3547 # XXX: The primary mechanism for storing circ history is now handled
3548 # by tracking real circulation objects instead of bibs in a bucket.
3549 # However, this code is disabled by default and could be useful
3550 # some day, so may as well leave it for now.
3551 sub append_reading_list {
3555 $self->is_checkout and
3561 # verify history is globally enabled and uses the bucket mechanism
3562 my $htype = OpenSRF::Utils::SettingsClient->new->config_value(
3563 apps => 'open-ils.circ' => app_settings => 'checkout_history_mechanism');
3565 return undef unless $htype and $htype eq 'bucket';
3567 my $e = new_editor(xact => 1, requestor => $self->editor->requestor);
3569 # verify the patron wants to retain the hisory
3570 my $setting = $e->search_actor_user_setting(
3571 {usr => $self->patron->id, name => 'circ.keep_checkout_history'})->[0];
3573 unless($setting and $setting->value) {
3578 my $bkt = $e->search_container_copy_bucket(
3579 {owner => $self->patron->id, btype => 'circ_history'})->[0];
3584 # find the next item position
3585 my $last_item = $e->search_container_copy_bucket_item(
3586 {bucket => $bkt->id}, {order_by => {ccbi => 'pos desc'}, limit => 1})->[0];
3587 $pos = $last_item->pos + 1 if $last_item;
3590 # create the history bucket if necessary
3591 $bkt = Fieldmapper::container::copy_bucket->new;
3592 $bkt->owner($self->patron->id);
3594 $bkt->btype('circ_history');
3596 $e->create_container_copy_bucket($bkt) or return $e->die_event;
3599 my $item = Fieldmapper::container::copy_bucket_item->new;
3601 $item->bucket($bkt->id);
3602 $item->target_copy($self->copy->id);
3605 $e->create_container_copy_bucket_item($item) or return $e->die_event;
3612 sub make_trigger_events {
3614 return unless $self->circ;
3615 $U->create_events_for_hook('checkout', $self->circ, $self->circ_lib) if $self->is_checkout;
3616 $U->create_events_for_hook('checkin', $self->circ, $self->circ_lib) if $self->is_checkin;
3617 $U->create_events_for_hook('renewal', $self->circ, $self->circ_lib) if $self->is_renewal;
3622 sub checkin_handle_lost_now_found {
3623 my ($self, $bill_type) = @_;
3625 # ------------------------------------------------------------------
3626 # remove charge from patron's account if lost item is returned
3627 # ------------------------------------------------------------------
3629 my $bills = $self->editor->search_money_billing(
3631 xact => $self->circ->id,
3636 $logger->debug("voiding lost item charge of ".scalar(@$bills));
3637 for my $bill (@$bills) {
3638 if( !$U->is_true($bill->voided) ) {
3639 $logger->info("lost item returned - voiding bill ".$bill->id);
3641 $bill->void_time('now');
3642 $bill->voider($self->editor->requestor->id);
3643 my $note = ($bill->note) ? $bill->note . "\n" : '';
3644 $bill->note("${note}System: VOIDED FOR LOST ITEM RETURNED");
3646 $self->bail_on_events($self->editor->event)
3647 unless $self->editor->update_money_billing($bill);
3652 sub checkin_handle_lost_now_found_restore_od {
3654 my $circ_lib = shift;
3656 # ------------------------------------------------------------------
3657 # restore those overdue charges voided when item was set to lost
3658 # ------------------------------------------------------------------
3660 my $ods = $self->editor->search_money_billing(
3662 xact => $self->circ->id,
3667 $logger->debug("returning overdue charges pre-lost ".scalar(@$ods));
3668 for my $bill (@$ods) {
3669 if( $U->is_true($bill->voided) ) {
3670 $logger->info("lost item returned - restoring overdue ".$bill->id);
3672 $bill->clear_void_time;
3673 $bill->voider($self->editor->requestor->id);
3674 my $note = ($bill->note) ? $bill->note . "\n" : '';
3675 $bill->note("${note}System: LOST RETURNED - OVERDUES REINSTATED");
3677 $self->bail_on_events($self->editor->event)
3678 unless $self->editor->update_money_billing($bill);
3683 # ------------------------------------------------------------------
3684 # Lost-then-found item checked in. This sub generates new overdue
3685 # fines, beyond the point of any existing and possibly voided
3686 # overdue fines, up to the point of final checkin time (or max fine
3688 # ------------------------------------------------------------------
3689 sub generate_lost_overdue_fines {
3691 my $circ = $self->circ;
3692 my $e = $self->editor;
3694 # Re-open the transaction so the fine generator can see it
3695 if($circ->xact_finish or $circ->stop_fines) {
3697 $circ->clear_xact_finish;
3698 $circ->clear_stop_fines;
3699 $circ->clear_stop_fines_time;
3700 $e->update_action_circulation($circ) or return $e->die_event;
3704 $e->xact_begin; # generate_fines expects an in-xact editor
3705 $self->generate_fines;
3706 $circ = $self->circ; # generate fines re-fetches the circ
3710 # Re-close the transaction if no money is owed
3711 my ($obt) = $U->fetch_mbts($circ->id, $e);
3712 if ($obt and $obt->balance_owed == 0) {
3713 $circ->xact_finish('now');
3717 # Set stop fines if the fine generator didn't have to
3718 unless($circ->stop_fines) {
3719 $circ->stop_fines(OILS_STOP_FINES_CHECKIN);
3720 $circ->stop_fines_time('now');
3724 # update the event data sent to the caller within the transaction
3725 $self->checkin_flesh_events;
3728 $e->update_action_circulation($circ) or return $e->die_event;