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
547 my $type = ref($self) or die "$self is not an object";
549 my $name = $AUTOLOAD;
552 unless (grep { $_ eq $name } @AUTOLOAD_FIELDS) {
553 $logger->error("circulator: $type: invalid autoload field: $name");
554 die "$type: invalid autoload field: $name\n"
559 *{"${type}::${name}"} = sub {
562 $s->{$name} = $v if defined $v;
566 return $self->$name($data);
571 my( $class, $auth, %args ) = @_;
572 $class = ref($class) || $class;
573 my $self = bless( {}, $class );
576 $self->editor(new_editor(xact => 1, authtoken => $auth));
578 unless( $self->editor->checkauth ) {
579 $self->bail_on_events($self->editor->event);
583 $self->cache_handle(OpenSRF::Utils::Cache->new('global'));
585 $self->$_($args{$_}) for keys %args;
588 ($self->circ_lib) ? $self->circ_lib : $self->editor->requestor->ws_ou);
590 # if this is a renewal, default to desk_renewal
591 $self->desk_renewal(1) unless
592 $self->opac_renewal or $self->phone_renewal or $self->sip_renewal;
594 $self->capture('') unless $self->capture;
596 unless(%user_groups) {
597 my $gps = $self->editor->retrieve_all_permission_grp_tree;
598 %user_groups = map { $_->id => $_ } @$gps;
605 # --------------------------------------------------------------------------
606 # True if we should discontinue processing
607 # --------------------------------------------------------------------------
609 my( $self, $bool ) = @_;
610 if( defined $bool ) {
611 $logger->info("circulator: BAILING OUT") if $bool;
612 $self->{bail_out} = $bool;
614 return $self->{bail_out};
619 my( $self, @evts ) = @_;
622 $e->{payload} = $self->copy if
623 ($e->{textcode} eq 'COPY_NOT_AVAILABLE');
625 $logger->info("circulator: pushing event ".$e->{textcode});
626 push( @{$self->events}, $e ) unless
627 grep { $_->{textcode} eq $e->{textcode} } @{$self->events};
633 return '' if $self->skip_permit_key;
634 my $key = md5_hex( time() . rand() . "$$" );
635 $self->cache_handle->put_cache( "oils_permit_key_$key", 1, 300 );
636 return $self->permit_key($key);
639 sub check_permit_key {
641 return 1 if $self->skip_permit_key;
642 my $key = $self->permit_key;
643 return 0 unless $key;
644 my $k = "oils_permit_key_$key";
645 my $one = $self->cache_handle->get_cache($k);
646 $self->cache_handle->delete_cache($k);
647 return ($one) ? 1 : 0;
650 sub seems_like_reservation {
653 # Some words about the following method:
654 # 1) It requires the VIEW_USER permission, but that's not an
655 # issue, right, since all staff should have that?
656 # 2) It returns only one reservation at a time, even if an item can be
657 # and is currently overbooked. Hmmm....
658 my $booking_ses = create OpenSRF::AppSession("open-ils.booking");
659 my $result = $booking_ses->request(
660 "open-ils.booking.reservations.by_returnable_resource_barcode",
661 $self->editor->authtoken,
664 $booking_ses->disconnect;
666 return $self->bail_on_events($result) if defined $U->event_code($result);
669 $self->reservation(shift @$result);
677 # save_trimmed_copy() used just to be a block in mk_env(), but was separated for re-use
678 sub save_trimmed_copy {
679 my ($self, $copy) = @_;
682 $self->volume($copy->call_number);
683 $self->title($self->volume->record);
684 $self->copy->call_number($self->volume->id);
685 $self->volume->record($self->title->id);
686 $self->is_precat(1) if $self->volume->id == OILS_PRECAT_CALL_NUMBER;
687 if($self->copy->deposit_amount and $self->copy->deposit_amount > 0) {
688 $self->is_deposit(1) if $U->is_true($self->copy->deposit);
689 $self->is_rental(1) unless $U->is_true($self->copy->deposit);
695 my $e = $self->editor;
697 # --------------------------------------------------------------------------
698 # Grab the fleshed copy
699 # --------------------------------------------------------------------------
700 unless($self->is_noncat) {
703 $copy = $e->retrieve_asset_copy(
704 [$self->copy_id, $MK_ENV_FLESH ]) or return $e->event;
706 } elsif( $self->copy_barcode ) {
708 $copy = $e->search_asset_copy(
709 [{barcode => $self->copy_barcode, deleted => 'f'}, $MK_ENV_FLESH ])->[0];
710 } elsif( $self->reservation ) {
711 my $res = $e->json_query(
713 "select" => {"acp" => ["id"]},
718 "field" => "barcode",
722 "field" => "current_resource"
730 "id" => (ref $self->reservation) ?
731 $self->reservation->id : $self->reservation
736 if (ref $res eq "ARRAY" and scalar @$res) {
737 $logger->info("circulator: mapped reservation " .
738 $self->reservation . " to copy " . $res->[0]->{"id"});
739 $copy = $e->retrieve_asset_copy([$res->[0]->{"id"}, $MK_ENV_FLESH]);
744 $self->save_trimmed_copy($copy);
746 # We can't renew if there is no copy
747 return $self->bail_on_events(OpenILS::Event->new('ASSET_COPY_NOT_FOUND'))
748 if $self->is_renewal;
753 # --------------------------------------------------------------------------
755 # --------------------------------------------------------------------------
759 flesh_fields => {au => [ qw/ card / ]}
762 if( $self->patron_id ) {
763 $patron = $e->retrieve_actor_user([$self->patron_id, $flesh])
764 or return $self->bail_on_events(OpenILS::Event->new('ACTOR_USER_NOT_FOUND'));
766 } elsif( $self->patron_barcode ) {
768 # note: throwing ACTOR_USER_NOT_FOUND instead of ACTOR_CARD_NOT_FOUND is intentional
769 my $card = $e->search_actor_card({barcode => $self->patron_barcode})->[0]
770 or return $self->bail_on_events(OpenILS::Event->new('ACTOR_USER_NOT_FOUND'));
772 $patron = $e->retrieve_actor_user($card->usr)
773 or return $self->bail_on_events(OpenILS::Event->new('ACTOR_USER_NOT_FOUND'));
775 # Use the card we looked up, not the patron's primary, for card active checks
776 $patron->card($card);
779 if( my $copy = $self->copy ) {
782 $flesh->{flesh_fields}->{circ} = ['usr'];
784 my $circ = $e->search_action_circulation([
785 {target_copy => $copy->id, checkin_time => undef}, $flesh
789 $patron = $circ->usr;
790 $circ->usr($patron->id); # de-flesh for consistency
796 return $self->bail_on_events(OpenILS::Event->new('ACTOR_USER_NOT_FOUND'))
797 unless $self->patron($patron) or $self->is_checkin;
799 unless($self->is_checkin) {
801 # Check for inactivity and patron reg. expiration
803 $self->bail_on_events(OpenILS::Event->new('PATRON_INACTIVE'))
804 unless $U->is_true($patron->active);
806 $self->bail_on_events(OpenILS::Event->new('PATRON_CARD_INACTIVE'))
807 unless $U->is_true($patron->card->active);
809 my $expire = DateTime::Format::ISO8601->new->parse_datetime(
810 cleanse_ISO8601($patron->expire_date));
812 $self->bail_on_events(OpenILS::Event->new('PATRON_ACCOUNT_EXPIRED'))
813 if( CORE::time > $expire->epoch ) ;
817 # --------------------------------------------------------------------------
818 # This builds the script runner environment and fetches most of the
820 # --------------------------------------------------------------------------
821 sub mk_script_runner {
827 qw/copy copy_barcode copy_id patron
828 patron_id patron_barcode volume title editor/;
830 # Translate our objects into the ScriptBuilder args hash
831 $$args{$_} = $self->$_() for @fields;
833 $args->{ignore_user_status} = 1 if $self->is_checkin;
834 $$args{fetch_patron_by_circ_copy} = 1;
835 $$args{fetch_patron_circ_info} = 1 unless $self->is_checkin;
837 if( my $pco = $self->pending_checkouts ) {
838 $logger->info("circulator: we were given a pending checkouts number of $pco");
839 $$args{patronItemsOut} = $pco;
842 # This fetches most of the objects we need
843 $self->script_runner(
844 OpenILS::Application::Circ::ScriptBuilder->build($args));
846 # Now we translate the ScriptBuilder objects back into self
847 $self->$_($$args{$_}) for @fields;
849 my @evts = @{$args->{_events}} if $args->{_events};
851 $logger->debug("circulator: script builder returned events: @evts") if @evts;
855 # Anything besides ASSET_COPY_NOT_FOUND will stop processing
856 if(!$self->is_noncat and
858 $evts[0]->{textcode} eq 'ASSET_COPY_NOT_FOUND') {
862 my @e = grep { $_->{textcode} ne 'ASSET_COPY_NOT_FOUND' } @evts;
863 return $self->bail_on_events(@e);
868 $self->is_precat(1) if $self->copy->call_number == OILS_PRECAT_CALL_NUMBER;
869 if($self->copy->deposit_amount and $self->copy->deposit_amount > 0) {
870 $self->is_deposit(1) if $U->is_true($self->copy->deposit);
871 $self->is_rental(1) unless $U->is_true($self->copy->deposit);
875 # We can't renew if there is no copy
876 return $self->bail_on_events(@evts) if
877 $self->is_renewal and !$self->copy;
879 # Set some circ-specific flags in the script environment
880 my $evt = "environment";
881 $self->script_runner->insert("$evt.isRenewal", ($self->is_renewal) ? 1 : undef);
883 if( $self->is_noncat ) {
884 $self->script_runner->insert("$evt.isNonCat", 1);
885 $self->script_runner->insert("$evt.nonCatType", $self->noncat_type);
888 if( $self->is_precat ) {
889 $self->script_runner->insert("environment.isPrecat", 1, 1);
892 $self->script_runner->add_path( $_ ) for @$script_libs;
897 # --------------------------------------------------------------------------
898 # Does the circ permit work
899 # --------------------------------------------------------------------------
903 $self->log_me("do_permit()");
905 unless( $self->editor->requestor->id == $self->patron->id ) {
906 return $self->bail_on_events($self->editor->event)
907 unless( $self->editor->allowed('VIEW_PERMIT_CHECKOUT') );
910 $self->check_captured_holds();
911 $self->do_copy_checks();
912 return if $self->bail_out;
913 $self->run_patron_permit_scripts();
914 $self->run_copy_permit_scripts()
915 unless $self->is_precat or $self->is_noncat;
916 $self->check_item_deposit_events();
917 $self->override_events();
918 return if $self->bail_out;
920 if($self->is_precat and not $self->request_precat) {
923 'ITEM_NOT_CATALOGED', payload => $self->mk_permit_key));
924 return $self->bail_out(1) unless $self->is_renewal;
928 OpenILS::Event->new('SUCCESS', payload => $self->mk_permit_key));
931 sub check_item_deposit_events {
933 $self->push_events(OpenILS::Event->new('ITEM_DEPOSIT_REQUIRED', payload => $self->copy))
934 if $self->is_deposit and not $self->is_deposit_exempt;
935 $self->push_events(OpenILS::Event->new('ITEM_RENTAL_FEE_REQUIRED', payload => $self->copy))
936 if $self->is_rental and not $self->is_rental_exempt;
939 # returns true if the user is not required to pay deposits
940 sub is_deposit_exempt {
942 my $pid = (ref $self->patron->profile) ?
943 $self->patron->profile->id : $self->patron->profile;
944 my $groups = $U->ou_ancestor_setting_value(
945 $self->circ_lib, 'circ.deposit.exempt_groups', $self->editor);
946 for my $grp (@$groups) {
947 return 1 if $self->is_group_descendant($grp, $pid);
952 # returns true if the user is not required to pay rental fees
953 sub is_rental_exempt {
955 my $pid = (ref $self->patron->profile) ?
956 $self->patron->profile->id : $self->patron->profile;
957 my $groups = $U->ou_ancestor_setting_value(
958 $self->circ_lib, 'circ.rental.exempt_groups', $self->editor);
959 for my $grp (@$groups) {
960 return 1 if $self->is_group_descendant($grp, $pid);
965 sub is_group_descendant {
966 my($self, $p_id, $c_id) = @_;
967 return 0 unless defined $p_id and defined $c_id;
968 return 1 if $c_id == $p_id;
969 while(my $grp = $user_groups{$c_id}) {
970 $c_id = $grp->parent;
971 return 0 unless defined $c_id;
972 return 1 if $c_id == $p_id;
977 sub check_captured_holds {
979 my $copy = $self->copy;
980 my $patron = $self->patron;
982 return undef unless $copy;
984 my $s = $U->copy_status($copy->status)->id;
985 return unless $s == OILS_COPY_STATUS_ON_HOLDS_SHELF;
986 $logger->info("circulator: copy is on holds shelf, searching for the correct hold");
988 # Item is on the holds shelf, make sure it's going to the right person
989 my $holds = $self->editor->search_action_hold_request(
992 current_copy => $copy->id ,
993 capture_time => { '!=' => undef },
994 cancel_time => undef,
995 fulfillment_time => undef
1001 if( $holds and $$holds[0] ) {
1002 return undef if $$holds[0]->usr == $patron->id;
1005 $logger->info("circulator: this copy is needed by a different patron to fulfill a hold");
1007 $self->push_events(OpenILS::Event->new('ITEM_ON_HOLDS_SHELF'));
1011 sub do_copy_checks {
1013 my $copy = $self->copy;
1014 return unless $copy;
1016 my $stat = $U->copy_status($copy->status)->id;
1018 # We cannot check out a copy if it is in-transit
1019 if( $stat == OILS_COPY_STATUS_IN_TRANSIT ) {
1020 return $self->bail_on_events(OpenILS::Event->new('COPY_IN_TRANSIT'));
1023 $self->handle_claims_returned();
1024 return if $self->bail_out;
1026 # no claims returned circ was found, check if there is any open circ
1027 unless( $self->is_renewal ) {
1029 my $circs = $self->editor->search_action_circulation(
1030 { target_copy => $copy->id, checkin_time => undef }
1033 if(my $old_circ = $circs->[0]) { # an open circ was found
1035 my $payload = {copy => $copy};
1037 if($old_circ->usr == $self->patron->id) {
1039 $payload->{old_circ} = $old_circ;
1041 # If there is an open circulation on the checkout item and an auto-renew
1042 # interval is defined, inform the caller that they should go
1043 # ahead and renew the item instead of warning about open circulations.
1045 my $auto_renew_intvl = $U->ou_ancestor_setting_value(
1047 'circ.checkout_auto_renew_age',
1051 if($auto_renew_intvl) {
1052 my $intvl_seconds = OpenSRF::Utils->interval_to_seconds($auto_renew_intvl);
1053 my $checkout_time = DateTime::Format::ISO8601->new->parse_datetime( cleanse_ISO8601($old_circ->xact_start) );
1055 if(DateTime->now > $checkout_time->add(seconds => $intvl_seconds)) {
1056 $payload->{auto_renew} = 1;
1061 return $self->bail_on_events(
1062 OpenILS::Event->new('OPEN_CIRCULATION_EXISTS', payload => $payload)
1068 my $LEGACY_CIRC_EVENT_MAP = {
1069 'no_item' => 'ITEM_NOT_CATALOGED',
1070 'actor.usr.barred' => 'PATRON_BARRED',
1071 'asset.copy.circulate' => 'COPY_CIRC_NOT_ALLOWED',
1072 'asset.copy.status' => 'COPY_NOT_AVAILABLE',
1073 'asset.copy_location.circulate' => 'COPY_CIRC_NOT_ALLOWED',
1074 'config.circ_matrix_test.circulate' => 'COPY_CIRC_NOT_ALLOWED',
1075 'config.circ_matrix_test.max_items_out' => 'PATRON_EXCEEDS_CHECKOUT_COUNT',
1076 'config.circ_matrix_test.max_overdue' => 'PATRON_EXCEEDS_OVERDUE_COUNT',
1077 'config.circ_matrix_test.max_fines' => 'PATRON_EXCEEDS_FINES',
1078 'config.circ_matrix_circ_mod_test' => 'PATRON_EXCEEDS_CHECKOUT_COUNT',
1082 # ---------------------------------------------------------------------
1083 # This pushes any patron-related events into the list but does not
1084 # set bail_out for any events
1085 # ---------------------------------------------------------------------
1086 sub run_patron_permit_scripts {
1088 my $runner = $self->script_runner;
1089 my $patronid = $self->patron->id;
1093 if(!$self->legacy_script_support) {
1095 my $results = $self->run_indb_circ_test;
1096 unless($self->circ_test_success) {
1097 # no_item result is OK during noncat checkout
1098 unless(@$results == 1 && $results->[0]->{fail_part} eq 'no_item' and $self->is_noncat) {
1099 push @allevents, $self->matrix_test_result_events;
1105 # ---------------------------------------------------------------------
1106 # # Now run the patron permit script
1107 # ---------------------------------------------------------------------
1108 $runner->load($self->circ_permit_patron);
1109 my $result = $runner->run or
1110 throw OpenSRF::EX::ERROR ("Circ Permit Patron Script Died: $@");
1112 my $patron_events = $result->{events};
1114 OpenILS::Utils::Penalty->calculate_penalties($self->editor, $self->patron->id, $self->circ_lib);
1115 my $mask = ($self->is_renewal) ? 'RENEW' : 'CIRC';
1116 my $penalties = OpenILS::Utils::Penalty->retrieve_penalties($self->editor, $patronid, $self->circ_lib, $mask);
1117 $penalties = $penalties->{fatal_penalties};
1119 for my $pen (@$penalties) {
1120 my $event = OpenILS::Event->new($pen->name);
1121 $event->{desc} = $pen->label;
1122 push(@allevents, $event);
1125 push(@allevents, OpenILS::Event->new($_)) for (@$patron_events);
1129 $_->{payload} = $self->copy if
1130 ($_->{textcode} eq 'COPY_NOT_AVAILABLE');
1133 $logger->info("circulator: permit_patron script returned events: @allevents") if @allevents;
1135 $self->push_events(@allevents);
1138 sub matrix_test_result_codes {
1140 map { $_->{"fail_part"} } @{$self->matrix_test_result};
1143 sub matrix_test_result_events {
1146 my $event = new OpenILS::Event(
1147 $LEGACY_CIRC_EVENT_MAP->{$_->{"fail_part"}} || $_->{"fail_part"}
1149 $event->{"payload"} = {"fail_part" => $_->{"fail_part"}};
1151 } (@{$self->matrix_test_result});
1154 sub run_indb_circ_test {
1156 return $self->matrix_test_result if $self->matrix_test_result;
1158 my $dbfunc = ($self->is_renewal) ?
1159 'action.item_user_renew_test' : 'action.item_user_circ_test';
1161 if( $self->is_precat && $self->request_precat) {
1162 $self->make_precat_copy;
1163 return if $self->bail_out;
1166 my $results = $self->editor->json_query(
1170 ($self->is_noncat or ($self->is_precat and !$self->override and !$self->is_renewal)) ? undef : $self->copy->id,
1176 $self->circ_test_success($U->is_true($results->[0]->{success}));
1178 if(my $mp = $results->[0]->{matchpoint}) {
1179 $logger->info("circulator: circ policy test found matchpoint built via rows " . $results->[0]->{buildrows});
1180 $self->circ_matrix_matchpoint($self->editor->retrieve_config_circ_matrix_matchpoint($mp));
1181 $self->circ_matrix_matchpoint->duration_rule($self->editor->retrieve_config_rules_circ_duration($results->[0]->{duration_rule}));
1182 if(defined($results->[0]->{renewals})) {
1183 $self->circ_matrix_matchpoint->duration_rule->max_renewals($results->[0]->{renewals});
1185 $self->circ_matrix_matchpoint->recurring_fine_rule($self->editor->retrieve_config_rules_recurring_fine($results->[0]->{recurring_fine_rule}));
1186 if(defined($results->[0]->{grace_period})) {
1187 $self->circ_matrix_matchpoint->recurring_fine_rule->grace_period($results->[0]->{grace_period});
1189 $self->circ_matrix_matchpoint->max_fine_rule($self->editor->retrieve_config_rules_max_fine($results->[0]->{max_fine_rule}));
1190 $self->circ_matrix_matchpoint->hard_due_date($self->editor->retrieve_config_hard_due_date($results->[0]->{hard_due_date}));
1193 return $self->matrix_test_result($results);
1196 # ---------------------------------------------------------------------
1197 # given a use and copy, this will calculate the circulation policy
1198 # parameters. Only works with in-db circ.
1199 # ---------------------------------------------------------------------
1203 return OpenILS::Event->new('ASSET_COPY_NOT_FOUND') unless $self->copy;
1205 $self->run_indb_circ_test;
1208 circ_test_success => $self->circ_test_success,
1209 failure_events => [],
1210 failure_codes => [],
1211 matchpoint => $self->circ_matrix_matchpoint
1214 unless($self->circ_test_success) {
1215 $results->{"failure_codes"} = [ $self->matrix_test_result_codes ];
1216 $results->{"failure_events"} = [ $self->matrix_test_result_events ];
1219 if($self->circ_matrix_matchpoint) {
1220 my $duration_rule = $self->circ_matrix_matchpoint->duration_rule;
1221 my $recurring_fine_rule = $self->circ_matrix_matchpoint->recurring_fine_rule;
1222 my $max_fine_rule = $self->circ_matrix_matchpoint->max_fine_rule;
1223 my $hard_due_date = $self->circ_matrix_matchpoint->hard_due_date;
1225 my $policy = $self->get_circ_policy(
1226 $duration_rule, $recurring_fine_rule, $max_fine_rule, $hard_due_date);
1228 $$results{$_} = $$policy{$_} for keys %$policy;
1234 # ---------------------------------------------------------------------
1235 # Loads the circ policy info for duration, recurring fine, and max
1236 # fine based on the current copy
1237 # ---------------------------------------------------------------------
1238 sub get_circ_policy {
1239 my($self, $duration_rule, $recurring_fine_rule, $max_fine_rule, $hard_due_date) = @_;
1242 duration_rule => $duration_rule->name,
1243 recurring_fine_rule => $recurring_fine_rule->name,
1244 max_fine_rule => $max_fine_rule->name,
1245 max_fine => $self->get_max_fine_amount($max_fine_rule),
1246 fine_interval => $recurring_fine_rule->recurrence_interval,
1247 renewal_remaining => $duration_rule->max_renewals,
1248 grace_period => $recurring_fine_rule->grace_period
1251 if($hard_due_date) {
1252 $policy->{duration_date_ceiling} = $hard_due_date->ceiling_date;
1253 $policy->{duration_date_ceiling_force} = $hard_due_date->forceto;
1256 $policy->{duration_date_ceiling} = undef;
1257 $policy->{duration_date_ceiling_force} = undef;
1260 $policy->{duration} = $duration_rule->shrt
1261 if $self->copy->loan_duration == OILS_CIRC_DURATION_SHORT;
1262 $policy->{duration} = $duration_rule->normal
1263 if $self->copy->loan_duration == OILS_CIRC_DURATION_NORMAL;
1264 $policy->{duration} = $duration_rule->extended
1265 if $self->copy->loan_duration == OILS_CIRC_DURATION_EXTENDED;
1267 $policy->{recurring_fine} = $recurring_fine_rule->low
1268 if $self->copy->fine_level == OILS_REC_FINE_LEVEL_LOW;
1269 $policy->{recurring_fine} = $recurring_fine_rule->normal
1270 if $self->copy->fine_level == OILS_REC_FINE_LEVEL_NORMAL;
1271 $policy->{recurring_fine} = $recurring_fine_rule->high
1272 if $self->copy->fine_level == OILS_REC_FINE_LEVEL_HIGH;
1277 sub get_max_fine_amount {
1279 my $max_fine_rule = shift;
1280 my $max_amount = $max_fine_rule->amount;
1282 # if is_percent is true then the max->amount is
1283 # use as a percentage of the copy price
1284 if ($U->is_true($max_fine_rule->is_percent)) {
1285 my $price = $U->get_copy_price($self->editor, $self->copy, $self->volume);
1286 $max_amount = $price * $max_fine_rule->amount / 100;
1288 $U->ou_ancestor_setting_value(
1290 'circ.max_fine.cap_at_price',
1294 my $price = $U->get_copy_price($self->editor, $self->copy, $self->volume);
1295 $max_amount = ( $price && $max_amount > $price ) ? $price : $max_amount;
1303 sub run_copy_permit_scripts {
1305 my $copy = $self->copy || return;
1306 my $runner = $self->script_runner;
1310 if(!$self->legacy_script_support) {
1311 my $results = $self->run_indb_circ_test;
1312 push @allevents, $self->matrix_test_result_events
1313 unless $self->circ_test_success;
1316 # ---------------------------------------------------------------------
1317 # Capture all of the copy permit events
1318 # ---------------------------------------------------------------------
1319 $runner->load($self->circ_permit_copy);
1320 my $result = $runner->run or
1321 throw OpenSRF::EX::ERROR ("Circ Permit Copy Script Died: $@");
1322 my $copy_events = $result->{events};
1324 # ---------------------------------------------------------------------
1325 # Now collect all of the events together
1326 # ---------------------------------------------------------------------
1327 push( @allevents, OpenILS::Event->new($_)) for @$copy_events;
1330 # See if this copy has an alert message
1331 my $ae = $self->check_copy_alert();
1332 push( @allevents, $ae ) if $ae;
1334 # uniquify the events
1335 my %hash = map { ($_->{ilsevent} => $_) } @allevents;
1336 @allevents = values %hash;
1338 $logger->info("circulator: permit_copy script returned events: @allevents") if @allevents;
1340 $self->push_events(@allevents);
1344 sub check_copy_alert {
1346 return undef if $self->is_renewal;
1347 return OpenILS::Event->new(
1348 'COPY_ALERT_MESSAGE', payload => $self->copy->alert_message)
1349 if $self->copy and $self->copy->alert_message;
1355 # --------------------------------------------------------------------------
1356 # If the call is overriding and has permissions to override every collected
1357 # event, the are cleared. Any event that the caller does not have
1358 # permission to override, will be left in the event list and bail_out will
1360 # XXX We need code in here to cancel any holds/transits on copies
1361 # that are being force-checked out
1362 # --------------------------------------------------------------------------
1363 sub override_events {
1365 my @events = @{$self->events};
1366 return unless @events;
1368 if(!$self->override) {
1369 return $self->bail_out(1)
1370 if( @events > 1 or $events[0]->{textcode} ne 'SUCCESS' );
1375 for my $e (@events) {
1376 my $tc = $e->{textcode};
1377 next if $tc eq 'SUCCESS';
1378 my $ov = "$tc.override";
1379 $logger->info("circulator: attempting to override event: $ov");
1381 return $self->bail_on_events($self->editor->event)
1382 unless( $self->editor->allowed($ov) );
1387 # --------------------------------------------------------------------------
1388 # If there is an open claimsreturn circ on the requested copy, close the
1389 # circ if overriding, otherwise bail out
1390 # --------------------------------------------------------------------------
1391 sub handle_claims_returned {
1393 my $copy = $self->copy;
1395 my $CR = $self->editor->search_action_circulation(
1397 target_copy => $copy->id,
1398 stop_fines => OILS_STOP_FINES_CLAIMSRETURNED,
1399 checkin_time => undef,
1403 return unless ($CR = $CR->[0]);
1407 # - If the caller has set the override flag, we will check the item in
1408 if($self->override) {
1410 $CR->checkin_time('now');
1411 $CR->checkin_scan_time('now');
1412 $CR->checkin_lib($self->circ_lib);
1413 $CR->checkin_workstation($self->editor->requestor->wsid);
1414 $CR->checkin_staff($self->editor->requestor->id);
1416 $evt = $self->editor->event
1417 unless $self->editor->update_action_circulation($CR);
1420 $evt = OpenILS::Event->new('CIRC_CLAIMS_RETURNED');
1423 $self->bail_on_events($evt) if $evt;
1428 # --------------------------------------------------------------------------
1429 # This performs the checkout
1430 # --------------------------------------------------------------------------
1434 $self->log_me("do_checkout()");
1436 # make sure perms are good if this isn't a renewal
1437 unless( $self->is_renewal ) {
1438 return $self->bail_on_events($self->editor->event)
1439 unless( $self->editor->allowed('COPY_CHECKOUT') );
1442 # verify the permit key
1443 unless( $self->check_permit_key ) {
1444 if( $self->permit_override ) {
1445 return $self->bail_on_events($self->editor->event)
1446 unless $self->editor->allowed('CIRC_PERMIT_OVERRIDE');
1448 return $self->bail_on_events(OpenILS::Event->new('CIRC_PERMIT_BAD_KEY'))
1452 # if this is a non-cataloged circ, build the circ and finish
1453 if( $self->is_noncat ) {
1454 $self->checkout_noncat;
1456 OpenILS::Event->new('SUCCESS',
1457 payload => { noncat_circ => $self->circ }));
1461 if( $self->is_precat ) {
1462 $self->make_precat_copy;
1463 return if $self->bail_out;
1465 } elsif( $self->copy->call_number == OILS_PRECAT_CALL_NUMBER ) {
1466 return $self->bail_on_events(OpenILS::Event->new('ITEM_NOT_CATALOGED'));
1469 $self->do_copy_checks;
1470 return if $self->bail_out;
1472 $self->run_checkout_scripts();
1473 return if $self->bail_out;
1475 $self->build_checkout_circ_object();
1476 return if $self->bail_out;
1478 my $modify_to_start = $self->booking_adjusted_due_date();
1479 return if $self->bail_out;
1481 $self->apply_modified_due_date($modify_to_start);
1482 return if $self->bail_out;
1484 return $self->bail_on_events($self->editor->event)
1485 unless $self->editor->create_action_circulation($self->circ);
1487 # refresh the circ to force local time zone for now
1488 $self->circ($self->editor->retrieve_action_circulation($self->circ->id));
1490 $self->copy->status(OILS_COPY_STATUS_CHECKED_OUT);
1492 return if $self->bail_out;
1494 $self->apply_deposit_fee();
1495 return if $self->bail_out;
1497 $self->handle_checkout_holds();
1498 return if $self->bail_out;
1500 # ------------------------------------------------------------------------------
1501 # Update the patron penalty info in the DB. Run it for permit-overrides
1502 # since the penalties are not updated during the permit phase
1503 # ------------------------------------------------------------------------------
1504 OpenILS::Utils::Penalty->calculate_penalties($self->editor, $self->patron->id, $self->circ_lib);
1506 my $record = $U->record_to_mvr($self->title) unless $self->is_precat;
1509 if($self->is_renewal) {
1510 # flesh the billing summary for the checked-in circ
1511 $pcirc = $self->editor->retrieve_action_circulation([
1513 {flesh => 2, flesh_fields => {circ => ['billable_transaction'], mbt => ['summary']}}
1518 OpenILS::Event->new('SUCCESS',
1520 copy => $U->unflesh_copy($self->copy),
1521 volume => $self->volume,
1522 circ => $self->circ,
1524 holds_fulfilled => $self->fulfilled_holds,
1525 deposit_billing => $self->deposit_billing,
1526 rental_billing => $self->rental_billing,
1527 parent_circ => $pcirc,
1528 patron => ($self->return_patron) ? $self->patron : undef,
1529 patron_money => $self->editor->retrieve_money_user_summary($self->patron->id)
1535 sub apply_deposit_fee {
1537 my $copy = $self->copy;
1539 ($self->is_deposit and not $self->is_deposit_exempt) or
1540 ($self->is_rental and not $self->is_rental_exempt);
1542 return if $self->is_deposit and $self->skip_deposit_fee;
1543 return if $self->is_rental and $self->skip_rental_fee;
1545 my $bill = Fieldmapper::money::billing->new;
1546 my $amount = $copy->deposit_amount;
1550 if($self->is_deposit) {
1551 $billing_type = OILS_BILLING_TYPE_DEPOSIT;
1553 $self->deposit_billing($bill);
1555 $billing_type = OILS_BILLING_TYPE_RENTAL;
1557 $self->rental_billing($bill);
1560 $bill->xact($self->circ->id);
1561 $bill->amount($amount);
1562 $bill->note(OILS_BILLING_NOTE_SYSTEM);
1563 $bill->billing_type($billing_type);
1564 $bill->btype($btype);
1565 $self->editor->create_money_billing($bill) or $self->bail_on_events($self->editor->event);
1567 $logger->info("circulator: charged $amount on checkout with billing type $billing_type");
1572 my $copy = $self->copy;
1574 my $stat = $copy->status if ref $copy->status;
1575 my $loc = $copy->location if ref $copy->location;
1576 my $circ_lib = $copy->circ_lib if ref $copy->circ_lib;
1578 $copy->status($stat->id) if $stat;
1579 $copy->location($loc->id) if $loc;
1580 $copy->circ_lib($circ_lib->id) if $circ_lib;
1581 $copy->editor($self->editor->requestor->id);
1582 $copy->edit_date('now');
1583 $copy->age_protect($copy->age_protect->id) if ref $copy->age_protect;
1585 return $self->bail_on_events($self->editor->event)
1586 unless $self->editor->update_asset_copy($self->copy);
1588 $copy->status($U->copy_status($copy->status));
1589 $copy->location($loc) if $loc;
1590 $copy->circ_lib($circ_lib) if $circ_lib;
1593 sub update_reservation {
1595 my $reservation = $self->reservation;
1597 my $usr = $reservation->usr;
1598 my $target_rt = $reservation->target_resource_type;
1599 my $target_r = $reservation->target_resource;
1600 my $current_r = $reservation->current_resource;
1602 $reservation->usr($usr->id) if ref $usr;
1603 $reservation->target_resource_type($target_rt->id) if ref $target_rt;
1604 $reservation->target_resource($target_r->id) if ref $target_r;
1605 $reservation->current_resource($current_r->id) if ref $current_r;
1607 return $self->bail_on_events($self->editor->event)
1608 unless $self->editor->update_booking_reservation($self->reservation);
1611 ($reservation, $evt) = $U->fetch_booking_reservation($reservation->id);
1612 $self->reservation($reservation);
1616 sub bail_on_events {
1617 my( $self, @evts ) = @_;
1618 $self->push_events(@evts);
1623 # ------------------------------------------------------------------------------
1624 # When an item is checked out, see if we can fulfill a hold for this patron
1625 # ------------------------------------------------------------------------------
1626 sub handle_checkout_holds {
1628 my $copy = $self->copy;
1629 my $patron = $self->patron;
1631 my $e = $self->editor;
1632 $self->fulfilled_holds([]);
1634 # pre/non-cats can't fulfill a hold
1635 return if $self->is_precat or $self->is_noncat;
1637 my $hold = $e->search_action_hold_request({
1638 current_copy => $copy->id ,
1639 cancel_time => undef,
1640 fulfillment_time => undef,
1642 {expire_time => undef},
1643 {expire_time => {'>' => 'now'}}
1647 if($hold and $hold->usr != $patron->id) {
1648 # reset the hold since the copy is now checked out
1650 $logger->info("circulator: un-targeting hold ".$hold->id.
1651 " because copy ".$copy->id." is getting checked out");
1653 $hold->clear_prev_check_time;
1654 $hold->clear_current_copy;
1655 $hold->clear_capture_time;
1656 $hold->clear_shelf_time;
1657 $hold->clear_shelf_expire_time;
1658 $hold->clear_current_shelf_lib;
1660 return $self->bail_on_event($e->event)
1661 unless $e->update_action_hold_request($hold);
1667 $hold = $self->find_related_user_hold($copy, $patron) or return;
1668 $logger->info("circulator: found related hold to fulfill in checkout");
1671 $logger->debug("circulator: checkout fulfilling hold " . $hold->id);
1673 # if the hold was never officially captured, capture it.
1674 $hold->current_copy($copy->id);
1675 $hold->capture_time('now') unless $hold->capture_time;
1676 $hold->fulfillment_time('now');
1677 $hold->fulfillment_staff($e->requestor->id);
1678 $hold->fulfillment_lib($self->circ_lib);
1680 return $self->bail_on_events($e->event)
1681 unless $e->update_action_hold_request($hold);
1683 $holdcode->delete_hold_copy_maps($e, $hold->id);
1684 return $self->fulfilled_holds([$hold->id]);
1688 # ------------------------------------------------------------------------------
1689 # If the circ.checkout_fill_related_hold setting is turned on and no hold for
1690 # the patron directly targets the checked out item, see if there is another hold
1691 # for the patron that could be fulfilled by the checked out item. Fulfill the
1692 # oldest hold and only fulfill 1 of them.
1694 # For "another hold":
1696 # First, check for one that the copy matches via hold_copy_map, ensuring that
1697 # *any* hold type that this copy could fill may end up filled.
1699 # Then, if circ.checkout_fill_related_hold_exact_match_only is not enabled, look
1700 # for a Title (T) or Volume (V) hold that matches the item. This allows items
1701 # that are non-requestable to count as capturing those hold types.
1702 # ------------------------------------------------------------------------------
1703 sub find_related_user_hold {
1704 my($self, $copy, $patron) = @_;
1705 my $e = $self->editor;
1707 return undef if $self->volume->id == OILS_PRECAT_CALL_NUMBER;
1709 return undef unless $U->ou_ancestor_setting_value(
1710 $self->circ_lib, 'circ.checkout_fills_related_hold', $e);
1712 # find the oldest unfulfilled hold that has not yet hit the holds shelf.
1714 select => {ahr => ['id']},
1723 fkey => 'current_copy',
1724 type => 'left' # there may be no current_copy
1731 fulfillment_time => undef,
1732 cancel_time => undef,
1734 {expire_time => undef},
1735 {expire_time => {'>' => 'now'}}
1739 target_copy => $self->copy->id
1743 {id => undef}, # left-join copy may be nonexistent
1744 {status => {'!=' => OILS_COPY_STATUS_ON_HOLDS_SHELF}},
1748 order_by => {ahr => {request_time => {direction => 'asc'}}},
1752 my $hold_info = $e->json_query($args)->[0];
1753 return $e->retrieve_action_hold_request($hold_info->{id}) if $hold_info;
1754 return undef if $U->ou_ancestor_setting_value(
1755 $self->circ_lib, 'circ.checkout_fills_related_hold_exact_match_only', $e);
1757 # find the oldest unfulfilled hold that has not yet hit the holds shelf.
1759 select => {ahr => ['id']},
1764 fkey => 'current_copy',
1765 type => 'left' # there may be no current_copy
1772 fulfillment_time => undef,
1773 cancel_time => undef,
1775 {expire_time => undef},
1776 {expire_time => {'>' => 'now'}}
1783 target => $self->volume->id
1789 target => $self->title->id
1795 {id => undef}, # left-join copy may be nonexistent
1796 {status => {'!=' => OILS_COPY_STATUS_ON_HOLDS_SHELF}},
1800 order_by => {ahr => {request_time => {direction => 'asc'}}},
1804 $hold_info = $e->json_query($args)->[0];
1805 return $e->retrieve_action_hold_request($hold_info->{id}) if $hold_info;
1810 sub run_checkout_scripts {
1815 my $runner = $self->script_runner;
1824 my $hard_due_date_name;
1826 if(!$self->legacy_script_support) {
1827 $self->run_indb_circ_test();
1828 $duration = $self->circ_matrix_matchpoint->duration_rule;
1829 $recurring = $self->circ_matrix_matchpoint->recurring_fine_rule;
1830 $max_fine = $self->circ_matrix_matchpoint->max_fine_rule;
1831 $hard_due_date = $self->circ_matrix_matchpoint->hard_due_date;
1835 $runner->load($self->circ_duration);
1837 my $result = $runner->run or
1838 throw OpenSRF::EX::ERROR ("Circ Duration Script Died: $@");
1840 $duration_name = $result->{durationRule};
1841 $recurring_name = $result->{recurringFinesRule};
1842 $max_fine_name = $result->{maxFine};
1843 $hard_due_date_name = $result->{hardDueDate};
1846 $duration_name = $duration->name if $duration;
1847 if( $duration_name ne OILS_UNLIMITED_CIRC_DURATION ) {
1850 ($duration, $evt) = $U->fetch_circ_duration_by_name($duration_name);
1851 return $self->bail_on_events($evt) if ($evt && !$nobail);
1853 ($recurring, $evt) = $U->fetch_recurring_fine_by_name($recurring_name);
1854 return $self->bail_on_events($evt) if ($evt && !$nobail);
1856 ($max_fine, $evt) = $U->fetch_max_fine_by_name($max_fine_name);
1857 return $self->bail_on_events($evt) if ($evt && !$nobail);
1859 if($hard_due_date_name) {
1860 ($hard_due_date, $evt) = $U->fetch_hard_due_date_by_name($hard_due_date_name);
1861 return $self->bail_on_events($evt) if ($evt && !$nobail);
1867 # The item circulates with an unlimited duration
1871 $hard_due_date = undef;
1874 $self->duration_rule($duration);
1875 $self->recurring_fines_rule($recurring);
1876 $self->max_fine_rule($max_fine);
1877 $self->hard_due_date($hard_due_date);
1881 sub build_checkout_circ_object {
1884 my $circ = Fieldmapper::action::circulation->new;
1885 my $duration = $self->duration_rule;
1886 my $max = $self->max_fine_rule;
1887 my $recurring = $self->recurring_fines_rule;
1888 my $hard_due_date = $self->hard_due_date;
1889 my $copy = $self->copy;
1890 my $patron = $self->patron;
1891 my $duration_date_ceiling;
1892 my $duration_date_ceiling_force;
1896 my $policy = $self->get_circ_policy($duration, $recurring, $max, $hard_due_date);
1897 $duration_date_ceiling = $policy->{duration_date_ceiling};
1898 $duration_date_ceiling_force = $policy->{duration_date_ceiling_force};
1900 my $dname = $duration->name;
1901 my $mname = $max->name;
1902 my $rname = $recurring->name;
1904 if($hard_due_date) {
1905 $hdname = $hard_due_date->name;
1908 $logger->debug("circulator: building circulation ".
1909 "with duration=$dname, maxfine=$mname, recurring=$rname, hard due date=$hdname");
1911 $circ->duration($policy->{duration});
1912 $circ->recurring_fine($policy->{recurring_fine});
1913 $circ->duration_rule($duration->name);
1914 $circ->recurring_fine_rule($recurring->name);
1915 $circ->max_fine_rule($max->name);
1916 $circ->max_fine($policy->{max_fine});
1917 $circ->fine_interval($recurring->recurrence_interval);
1918 $circ->renewal_remaining($duration->max_renewals);
1919 $circ->grace_period($policy->{grace_period});
1923 $logger->info("circulator: copy found with an unlimited circ duration");
1924 $circ->duration_rule(OILS_UNLIMITED_CIRC_DURATION);
1925 $circ->recurring_fine_rule(OILS_UNLIMITED_CIRC_DURATION);
1926 $circ->max_fine_rule(OILS_UNLIMITED_CIRC_DURATION);
1927 $circ->renewal_remaining(0);
1928 $circ->grace_period(0);
1931 $circ->target_copy( $copy->id );
1932 $circ->usr( $patron->id );
1933 $circ->circ_lib( $self->circ_lib );
1934 $circ->workstation($self->editor->requestor->wsid)
1935 if defined $self->editor->requestor->wsid;
1937 # renewals maintain a link to the parent circulation
1938 $circ->parent_circ($self->parent_circ);
1940 if( $self->is_renewal ) {
1941 $circ->opac_renewal('t') if $self->opac_renewal;
1942 $circ->phone_renewal('t') if $self->phone_renewal;
1943 $circ->desk_renewal('t') if $self->desk_renewal;
1944 $circ->renewal_remaining($self->renewal_remaining);
1945 $circ->circ_staff($self->editor->requestor->id);
1949 # if the user provided an overiding checkout time,
1950 # (e.g. the checkout really happened several hours ago), then
1951 # we apply that here. Does this need a perm??
1952 $circ->xact_start(cleanse_ISO8601($self->checkout_time))
1953 if $self->checkout_time;
1955 # if a patron is renewing, 'requestor' will be the patron
1956 $circ->circ_staff($self->editor->requestor->id);
1957 $circ->due_date( $self->create_due_date($circ->duration, $duration_date_ceiling, $duration_date_ceiling_force) ) if $circ->duration;
1962 sub do_reservation_pickup {
1965 $self->log_me("do_reservation_pickup()");
1967 $self->reservation->pickup_time('now');
1970 $self->reservation->current_resource &&
1971 $U->is_true($self->reservation->target_resource_type->catalog_item)
1973 # We used to try to set $self->copy and $self->patron here,
1974 # but that should already be done.
1976 $self->run_checkout_scripts(1);
1978 my $duration = $self->duration_rule;
1979 my $max = $self->max_fine_rule;
1980 my $recurring = $self->recurring_fines_rule;
1982 if ($duration && $max && $recurring) {
1983 my $policy = $self->get_circ_policy($duration, $recurring, $max);
1985 my $dname = $duration->name;
1986 my $mname = $max->name;
1987 my $rname = $recurring->name;
1989 $logger->debug("circulator: updating reservation ".
1990 "with duration=$dname, maxfine=$mname, recurring=$rname");
1992 $self->reservation->fine_amount($policy->{recurring_fine});
1993 $self->reservation->max_fine($policy->{max_fine});
1994 $self->reservation->fine_interval($recurring->recurrence_interval);
1997 $self->copy->status(OILS_COPY_STATUS_CHECKED_OUT);
1998 $self->update_copy();
2001 $self->reservation->fine_amount(
2002 $self->reservation->target_resource_type->fine_amount
2004 $self->reservation->max_fine(
2005 $self->reservation->target_resource_type->max_fine
2007 $self->reservation->fine_interval(
2008 $self->reservation->target_resource_type->fine_interval
2012 $self->update_reservation();
2015 sub do_reservation_return {
2017 my $request = shift;
2019 $self->log_me("do_reservation_return()");
2021 if (not ref $self->reservation) {
2022 my ($reservation, $evt) =
2023 $U->fetch_booking_reservation($self->reservation);
2024 return $self->bail_on_events($evt) if $evt;
2025 $self->reservation($reservation);
2028 $self->generate_fines(1);
2029 $self->reservation->return_time('now');
2030 $self->update_reservation();
2031 $self->reshelve_copy if $self->copy;
2033 if ( $self->reservation->current_resource && $self->reservation->current_resource->catalog_item ) {
2034 $self->copy( $self->reservation->current_resource->catalog_item );
2038 sub booking_adjusted_due_date {
2040 my $circ = $self->circ;
2041 my $copy = $self->copy;
2043 return undef unless $self->use_booking;
2047 if( $self->due_date ) {
2049 return $self->bail_on_events($self->editor->event)
2050 unless $self->editor->allowed('CIRC_OVERRIDE_DUE_DATE', $self->circ_lib);
2052 $circ->due_date(cleanse_ISO8601($self->due_date));
2056 return unless $copy and $circ->due_date;
2059 my $booking_items = $self->editor->search_booking_resource( { barcode => $copy->barcode } );
2060 if (@$booking_items) {
2061 my $booking_item = $booking_items->[0];
2062 my $resource_type = $self->editor->retrieve_booking_resource_type( $booking_item->type );
2064 my $stop_circ_setting = $U->ou_ancestor_setting_value( $self->circ_lib, 'circ.booking_reservation.stop_circ', $self->editor );
2065 my $shorten_circ_setting = $resource_type->elbow_room ||
2066 $U->ou_ancestor_setting_value( $self->circ_lib, 'circ.booking_reservation.default_elbow_room', $self->editor ) ||
2069 my $booking_ses = OpenSRF::AppSession->create( 'open-ils.booking' );
2070 my $bookings = $booking_ses->request(
2071 'open-ils.booking.reservations.filtered_id_list', $self->editor->authtoken,
2072 { resource => $booking_item->id, search_start => 'now', search_end => $circ->due_date, fields => { cancel_time => undef, return_time => undef}}
2074 $booking_ses->disconnect;
2076 my $dt_parser = DateTime::Format::ISO8601->new;
2077 my $due_date = $dt_parser->parse_datetime( cleanse_ISO8601($circ->due_date) );
2079 for my $bid (@$bookings) {
2081 my $booking = $self->editor->retrieve_booking_reservation( $bid );
2083 my $booking_start = $dt_parser->parse_datetime( cleanse_ISO8601($booking->start_time) );
2084 my $booking_end = $dt_parser->parse_datetime( cleanse_ISO8601($booking->end_time) );
2086 return $self->bail_on_events( OpenILS::Event->new('COPY_RESERVED') )
2087 if ($booking_start < DateTime->now);
2090 if ($U->is_true($stop_circ_setting)) {
2091 $self->bail_on_events( OpenILS::Event->new('COPY_RESERVED') );
2093 $due_date = $booking_start->subtract( seconds => interval_to_seconds($shorten_circ_setting) );
2094 $self->bail_on_events( OpenILS::Event->new('COPY_RESERVED') ) if ($due_date < DateTime->now);
2097 # We set the circ duration here only to affect the logic that will
2098 # later (in a DB trigger) mangle the time part of the due date to
2099 # 11:59pm. Having any circ duration that is not a whole number of
2100 # days is enough to prevent the "correction."
2101 my $new_circ_duration = $due_date->epoch - time;
2102 $new_circ_duration++ if $new_circ_duration % 86400 == 0;
2103 $circ->duration("$new_circ_duration seconds");
2105 $circ->due_date(cleanse_ISO8601($due_date->strftime('%FT%T%z')));
2109 return $self->bail_on_events($self->editor->event)
2110 unless $self->editor->allowed('CIRC_OVERRIDE_DUE_DATE', $self->circ_lib);
2116 sub apply_modified_due_date {
2118 my $shift_earlier = shift;
2119 my $circ = $self->circ;
2120 my $copy = $self->copy;
2122 if( $self->due_date ) {
2124 return $self->bail_on_events($self->editor->event)
2125 unless $self->editor->allowed('CIRC_OVERRIDE_DUE_DATE', $self->circ_lib);
2127 $circ->due_date(cleanse_ISO8601($self->due_date));
2131 # if the due_date lands on a day when the location is closed
2132 return unless $copy and $circ->due_date;
2134 #my $org = (ref $copy->circ_lib) ? $copy->circ_lib->id : $copy->circ_lib;
2136 # due-date overlap should be determined by the location the item
2137 # is checked out from, not the owning or circ lib of the item
2138 my $org = $self->circ_lib;
2140 $logger->info("circulator: circ searching for closed date overlap on lib $org".
2141 " with an item due date of ".$circ->due_date );
2143 my $dateinfo = $U->storagereq(
2144 'open-ils.storage.actor.org_unit.closed_date.overlap',
2145 $org, $circ->due_date );
2148 $logger->info("circulator: $dateinfo : circ due data / close date overlap found : due_date=".
2149 $circ->due_date." start=". $dateinfo->{start}.", end=".$dateinfo->{end});
2151 # XXX make the behavior more dynamic
2152 # for now, we just push the due date to after the close date
2153 if ($shift_earlier) {
2154 $circ->due_date($dateinfo->{start});
2156 $circ->due_date($dateinfo->{end});
2164 sub create_due_date {
2165 my( $self, $duration, $date_ceiling, $force_date ) = @_;
2167 # if there is a raw time component (e.g. from postgres),
2168 # turn it into an interval that interval_to_seconds can parse
2169 $duration =~ s/(\d{2}):(\d{2}):(\d{2})/$1 h $2 m $3 s/o;
2171 # for now, use the server timezone. TODO: use workstation org timezone
2172 my $due_date = DateTime->now(time_zone => 'local');
2174 # add the circ duration
2175 $due_date->add(seconds => OpenSRF::Utils->interval_to_seconds($duration));
2178 my $cdate = DateTime::Format::ISO8601->new->parse_datetime(cleanse_ISO8601($date_ceiling));
2179 if ($cdate > DateTime->now and ($cdate < $due_date or $U->is_true( $force_date ))) {
2180 $logger->info("circulator: overriding due date with date ceiling: $date_ceiling");
2185 # return ISO8601 time with timezone
2186 return $due_date->strftime('%FT%T%z');
2191 sub make_precat_copy {
2193 my $copy = $self->copy;
2196 $logger->debug("circulator: Pre-cat copy already exists in checkout: ID=" . $copy->id);
2198 $copy->editor($self->editor->requestor->id);
2199 $copy->edit_date('now');
2200 $copy->dummy_title($self->dummy_title || $copy->dummy_title || '');
2201 $copy->dummy_isbn($self->dummy_isbn || $copy->dummy_isbn || '');
2202 $copy->dummy_author($self->dummy_author || $copy->dummy_author || '');
2203 $copy->circ_modifier($self->circ_modifier || $copy->circ_modifier);
2204 $self->update_copy();
2208 $logger->info("circulator: Creating a new precataloged ".
2209 "copy in checkout with barcode " . $self->copy_barcode);
2211 $copy = Fieldmapper::asset::copy->new;
2212 $copy->circ_lib($self->circ_lib);
2213 $copy->creator($self->editor->requestor->id);
2214 $copy->editor($self->editor->requestor->id);
2215 $copy->barcode($self->copy_barcode);
2216 $copy->call_number(OILS_PRECAT_CALL_NUMBER);
2217 $copy->loan_duration(OILS_PRECAT_COPY_LOAN_DURATION);
2218 $copy->fine_level(OILS_PRECAT_COPY_FINE_LEVEL);
2220 $copy->dummy_title($self->dummy_title || "");
2221 $copy->dummy_author($self->dummy_author || "");
2222 $copy->dummy_isbn($self->dummy_isbn || "");
2223 $copy->circ_modifier($self->circ_modifier);
2226 # See if we need to override the circ_lib for the copy with a configured circ_lib
2227 # Setting is shortname of the org unit
2228 my $precat_circ_lib = $U->ou_ancestor_setting_value(
2229 $self->circ_lib, 'circ.pre_cat_copy_circ_lib', $self->editor);
2231 if($precat_circ_lib) {
2232 my $org = $self->editor->search_actor_org_unit({shortname => $precat_circ_lib})->[0];
2235 $self->bail_on_events($self->editor->event);
2239 $copy->circ_lib($org->id);
2243 unless( $self->copy($self->editor->create_asset_copy($copy)) ) {
2245 $self->push_events($self->editor->event);
2249 # this is a little bit of a hack, but we need to
2250 # get the copy into the script runner
2251 $self->script_runner->insert("environment.copy", $copy, 1) if $self->script_runner;
2255 sub checkout_noncat {
2261 my $lib = $self->noncat_circ_lib || $self->circ_lib;
2262 my $count = $self->noncat_count || 1;
2263 my $cotime = cleanse_ISO8601($self->checkout_time) || "";
2265 $logger->info("circulator: circ creating $count noncat circs with checkout time $cotime");
2269 ( $circ, $evt ) = OpenILS::Application::Circ::NonCat::create_non_cat_circ(
2270 $self->editor->requestor->id,
2278 $self->push_events($evt);
2286 # If a copy goes into transit and is then checked in before the transit checkin
2287 # interval has expired, push an event onto the overridable events list.
2288 sub check_transit_checkin_interval {
2291 # only concerned with in-transit items
2292 return unless $U->copy_status($self->copy->status)->id == OILS_COPY_STATUS_IN_TRANSIT;
2294 # no interval, no problem
2295 my $interval = $U->ou_ancestor_setting_value($self->circ_lib, 'circ.transit.min_checkin_interval');
2296 return unless $interval;
2298 # capture the transit so we don't have to fetch it again later during checkin
2300 $self->editor->search_action_transit_copy(
2301 {target_copy => $self->copy->id, dest_recv_time => undef}
2305 # transit from X to X for whatever reason has no min interval
2306 return if $self->transit->source == $self->transit->dest;
2308 my $seconds = OpenSRF::Utils->interval_to_seconds($interval);
2309 my $t_start = DateTime::Format::ISO8601->new->parse_datetime(cleanse_ISO8601($self->transit->source_send_time));
2310 my $horizon = $t_start->add(seconds => $seconds);
2312 # See if we are still within the transit checkin forbidden range
2313 $self->push_events(OpenILS::Event->new('TRANSIT_CHECKIN_INTERVAL_BLOCK'))
2314 if $horizon > DateTime->now;
2317 # Retarget local holds at checkin
2318 sub checkin_retarget {
2320 return unless $self->retarget_mode =~ m/retarget/; # Retargeting?
2321 return unless $self->is_checkin; # Renewals need not be checked
2322 return if $self->capture eq 'nocapture'; # Not capturing holds anyway? Move on.
2323 return if $self->is_precat; # No holds for precats
2324 return unless $self->circ_lib == $self->copy->circ_lib; # Item isn't "home"? Don't check.
2325 return unless $U->is_true($self->copy->holdable); # Not holdable, shouldn't capture holds.
2326 my $status = $U->copy_status($self->copy->status);
2327 return unless $U->is_true($status->holdable); # Current status not holdable means no hold will ever target the item
2328 # Specifically target items that are likely new (by status ID)
2329 return unless $status->id == OILS_COPY_STATUS_IN_PROCESS || $self->retarget_mode =~ m/\.all/;
2330 my $location = $self->copy->location;
2331 if(!ref($location)) {
2332 $location = $self->editor->retrieve_asset_copy_location($self->copy->location);
2333 $self->copy->location($location);
2335 return unless $U->is_true($location->holdable); # Don't bother on non-holdable locations
2337 # Fetch holds for the bib
2338 my ($result) = $holdcode->method_lookup('open-ils.circ.holds.retrieve_all_from_title')->run(
2339 $self->editor->authtoken,
2342 capture_time => undef, # No touching captured holds
2343 frozen => 'f', # Don't bother with frozen holds
2344 pickup_lib => $self->circ_lib # Only holds actually here
2347 # Error? Skip the step.
2348 return if exists $result->{"ilsevent"};
2352 foreach my $holdlist (keys %{$result}) {
2353 push @$holds, @{$result->{$holdlist}};
2356 return if scalar(@$holds) == 0; # No holds, no retargeting
2358 # Check for parts on this copy
2359 my $parts = $self->editor->search_asset_copy_part_map({ target_copy => $self->copy->id });
2360 my %parts_hash = ();
2361 %parts_hash = map {$_->id, 1} @$parts if @$parts;
2363 # Loop over holds in request-ish order
2364 # Stage 1: Get them into request-ish order
2365 # Also grab type and target for skipping low hanging ones
2366 $result = $self->editor->json_query({
2367 "select" => { "ahr" => ["id", "hold_type", "target"] },
2368 "from" => { "ahr" => { "au" => { "fkey" => "usr", "join" => "pgt"} } },
2369 "where" => { "id" => $holds },
2371 { "class" => "pgt", "field" => "hold_priority"},
2372 { "class" => "ahr", "field" => "cut_in_line", "direction" => "desc", "transform" => "coalesce", "params" => ['f']},
2373 { "class" => "ahr", "field" => "selection_depth", "direction" => "desc"},
2374 { "class" => "ahr", "field" => "request_time"}
2379 if (ref $result eq "ARRAY" and scalar @$result) {
2380 foreach (@{$result}) {
2381 # Copy level, but not this copy?
2382 next if ($_->{hold_type} eq 'C' or $_->{hold_type} eq 'R' or $_->{hold_type} eq 'F'
2383 and $_->{target} != $self->copy->id);
2384 # Volume level, but not this volume?
2385 next if ($_->{hold_type} eq 'V' and $_->{target} != $self->volume->id);
2386 if(@$parts) { # We have parts?
2388 next if ($_->{hold_type} eq 'T');
2389 # Skip part holds for parts not on this copy
2390 next if ($_->{hold_type} eq 'P' and not $parts_hash{$_->{target}});
2392 # No parts, no part holds
2393 next if ($_->{hold_type} eq 'P');
2395 # So much for easy stuff, attempt a retarget!
2396 my $tresult = $U->storagereq('open-ils.storage.action.hold_request.copy_targeter', undef, $_->{id}, $self->copy->id);
2397 if(ref $tresult eq "ARRAY" and scalar @$tresult) {
2398 last if(exists $tresult->[0]->{found_copy} and $tresult->[0]->{found_copy});
2406 $self->log_me("do_checkin()");
2408 return $self->bail_on_events(
2409 OpenILS::Event->new('ASSET_COPY_NOT_FOUND'))
2412 $self->check_transit_checkin_interval;
2413 $self->checkin_retarget;
2415 # the renew code and mk_env should have already found our circulation object
2416 unless( $self->circ ) {
2418 my $circs = $self->editor->search_action_circulation(
2419 { target_copy => $self->copy->id, checkin_time => undef });
2421 $self->circ($$circs[0]);
2423 # for now, just warn if there are multiple open circs on a copy
2424 $logger->warn("circulator: we have ".scalar(@$circs).
2425 " open circs for copy " .$self->copy->id."!!") if @$circs > 1;
2428 # run the fine generator against this circ, if this circ is there
2429 $self->generate_fines_start if $self->circ;
2431 if( $self->checkin_check_holds_shelf() ) {
2432 $self->bail_on_events(OpenILS::Event->new('NO_CHANGE'));
2433 $self->hold($U->fetch_open_hold_by_copy($self->copy->id));
2434 if($self->fake_hold_dest) {
2435 $self->hold->pickup_lib($self->circ_lib);
2437 $self->checkin_flesh_events;
2441 unless( $self->is_renewal ) {
2442 return $self->bail_on_events($self->editor->event)
2443 unless $self->editor->allowed('COPY_CHECKIN');
2446 $self->push_events($self->check_copy_alert());
2447 $self->push_events($self->check_checkin_copy_status());
2449 # if the circ is marked as 'claims returned', add the event to the list
2450 $self->push_events(OpenILS::Event->new('CIRC_CLAIMS_RETURNED'))
2451 if ($self->circ and $self->circ->stop_fines
2452 and $self->circ->stop_fines eq OILS_STOP_FINES_CLAIMSRETURNED);
2454 $self->check_circ_deposit();
2456 # handle the overridable events
2457 $self->override_events unless $self->is_renewal;
2458 return if $self->bail_out;
2460 if( $self->copy and !$self->transit ) {
2462 $self->editor->search_action_transit_copy(
2463 { target_copy => $self->copy->id, dest_recv_time => undef }
2469 $self->generate_fines_finish;
2470 $self->checkin_handle_circ;
2471 return if $self->bail_out;
2472 $self->checkin_changed(1);
2474 } elsif( $self->transit ) {
2475 my $hold_transit = $self->process_received_transit;
2476 $self->checkin_changed(1);
2478 if( $self->bail_out ) {
2479 $self->checkin_flesh_events;
2483 if( my $e = $self->check_checkin_copy_status() ) {
2484 # If the original copy status is special, alert the caller
2485 my $ev = $self->events;
2486 $self->events([$e]);
2487 $self->override_events;
2488 return if $self->bail_out;
2492 if( $hold_transit or
2493 $U->copy_status($self->copy->status)->id
2494 == OILS_COPY_STATUS_ON_HOLDS_SHELF ) {
2497 if( $hold_transit ) {
2498 $hold = $self->editor->retrieve_action_hold_request($hold_transit->hold);
2500 ($hold) = $U->fetch_open_hold_by_copy($self->copy->id);
2505 if( $hold and $hold->cancel_time ) { # this transited hold was cancelled mid-transit
2507 $logger->info("circulator: we received a transit on a cancelled hold " . $hold->id);
2508 $self->reshelve_copy(1);
2509 $self->cancelled_hold_transit(1);
2510 $self->notify_hold(0); # don't notify for cancelled holds
2511 $self->fake_hold_dest(0);
2512 return if $self->bail_out;
2516 # hold transited to correct location
2517 if($self->fake_hold_dest) {
2518 $hold->pickup_lib($self->circ_lib);
2520 $self->checkin_flesh_events;
2525 } elsif( $U->copy_status($self->copy->status)->id == OILS_COPY_STATUS_IN_TRANSIT ) {
2527 $logger->warn("circulator: we have a copy ".$self->copy->barcode.
2528 " that is in-transit, but there is no transit.. repairing");
2529 $self->reshelve_copy(1);
2530 return if $self->bail_out;
2533 if( $self->is_renewal ) {
2534 $self->finish_fines_and_voiding;
2535 return if $self->bail_out;
2536 $self->push_events(OpenILS::Event->new('SUCCESS'));
2540 # ------------------------------------------------------------------------------
2541 # Circulations and transits are now closed where necessary. Now go on to see if
2542 # this copy can fulfill a hold or needs to be routed to a different location
2543 # ------------------------------------------------------------------------------
2545 my $needed_for_something = 0; # formerly "needed_for_hold"
2547 if(!$self->noop) { # /not/ a no-op checkin, capture for hold or put item into transit
2549 if (!$self->remote_hold) {
2550 if ($self->use_booking) {
2551 my $potential_hold = $self->hold_capture_is_possible;
2552 my $potential_reservation = $self->reservation_capture_is_possible;
2554 if ($potential_hold and $potential_reservation) {
2555 $logger->info("circulator: item could fulfill either hold or reservation");
2556 $self->push_events(new OpenILS::Event(
2557 "HOLD_RESERVATION_CONFLICT",
2558 "hold" => $potential_hold,
2559 "reservation" => $potential_reservation
2561 return if $self->bail_out;
2562 } elsif ($potential_hold) {
2563 $needed_for_something =
2564 $self->attempt_checkin_hold_capture;
2565 } elsif ($potential_reservation) {
2566 $needed_for_something =
2567 $self->attempt_checkin_reservation_capture;
2570 $needed_for_something = $self->attempt_checkin_hold_capture;
2573 return if $self->bail_out;
2575 unless($needed_for_something) {
2576 my $circ_lib = (ref $self->copy->circ_lib) ?
2577 $self->copy->circ_lib->id : $self->copy->circ_lib;
2579 if( $self->remote_hold ) {
2580 $circ_lib = $self->remote_hold->pickup_lib;
2581 $logger->warn("circulator: Copy ".$self->copy->barcode.
2582 " is on a remote hold's shelf, sending to $circ_lib");
2585 $logger->debug("circulator: circlib=$circ_lib, workstation=".$self->circ_lib);
2587 my $suppress_transit = 0;
2589 if( $circ_lib != $self->circ_lib and not ($self->hold_as_transit and $self->remote_hold) ) {
2590 my $suppress_transit_source = $U->ou_ancestor_setting($self->circ_lib, 'circ.transit.suppress_non_hold');
2591 if($suppress_transit_source && $suppress_transit_source->{value}) {
2592 my $suppress_transit_dest = $U->ou_ancestor_setting($circ_lib, 'circ.transit.suppress_non_hold');
2593 if($suppress_transit_dest && $suppress_transit_source->{value} eq $suppress_transit_dest->{value}) {
2594 $logger->info("circulator: copy is within transit suppress group: ".$self->copy->barcode." ".$suppress_transit_source->{value});
2595 $suppress_transit = 1;
2600 if( $suppress_transit or ( $circ_lib == $self->circ_lib and not ($self->hold_as_transit and $self->remote_hold) ) ) {
2601 # copy is where it needs to be, either for hold or reshelving
2603 $self->checkin_handle_precat();
2604 return if $self->bail_out;
2607 # copy needs to transit "home", or stick here if it's a floating copy
2609 if ($U->is_true( $self->copy->floating ) && !$self->remote_hold) { # copy is floating, stick here
2610 $self->checkin_changed(1);
2611 $self->copy->circ_lib( $self->circ_lib );
2614 my $bc = $self->copy->barcode;
2615 $logger->info("circulator: copy $bc at the wrong location, sending to $circ_lib");
2616 $self->checkin_build_copy_transit($circ_lib);
2617 return if $self->bail_out;
2618 $self->push_events(OpenILS::Event->new('ROUTE_ITEM', org => $circ_lib));
2622 } else { # no-op checkin
2623 if ($U->is_true( $self->copy->floating )) { # XXX floating items still stick where they are even with no-op checkin?
2624 $self->checkin_changed(1);
2625 $self->copy->circ_lib( $self->circ_lib );
2630 if($self->claims_never_checked_out and
2631 $U->ou_ancestor_setting_value($self->circ->circ_lib, 'circ.claim_never_checked_out.mark_missing')) {
2633 # the item was not supposed to be checked out to the user and should now be marked as missing
2634 $self->copy->status(OILS_COPY_STATUS_MISSING);
2638 $self->reshelve_copy unless $needed_for_something;
2641 return if $self->bail_out;
2643 unless($self->checkin_changed) {
2645 $self->push_events(OpenILS::Event->new('NO_CHANGE'));
2646 my $stat = $U->copy_status($self->copy->status)->id;
2648 $self->hold($U->fetch_open_hold_by_copy($self->copy->id))
2649 if( $stat == OILS_COPY_STATUS_ON_HOLDS_SHELF );
2650 $self->bail_out(1); # no need to commit anything
2654 $self->push_events(OpenILS::Event->new('SUCCESS'))
2655 unless @{$self->events};
2658 $self->finish_fines_and_voiding;
2660 OpenILS::Utils::Penalty->calculate_penalties(
2661 $self->editor, $self->patron->id, $self->circ_lib) if $self->patron;
2663 $self->checkin_flesh_events;
2667 sub finish_fines_and_voiding {
2669 return unless $self->circ;
2671 # gather any updates to the circ after fine generation, if there was a circ
2672 $self->generate_fines_finish;
2674 return unless $self->backdate or $self->void_overdues;
2676 # void overdues after fine generation to prevent concurrent DB access to overdue billings
2677 my $note = 'System: Amnesty Checkin' if $self->void_overdues;
2679 my $evt = OpenILS::Application::Circ::CircCommon->void_overdues(
2680 $self->editor, $self->circ, $self->backdate, $note);
2682 return $self->bail_on_events($evt) if $evt;
2684 # make sure the circ isn't closed if we just voided some fines
2685 $evt = OpenILS::Application::Circ::CircCommon->reopen_xact($self->editor, $self->circ->id);
2686 return $self->bail_on_events($evt) if $evt;
2692 # if a deposit was payed for this item, push the event
2693 sub check_circ_deposit {
2695 return unless $self->circ;
2696 my $deposit = $self->editor->search_money_billing(
2698 xact => $self->circ->id,
2700 }, {idlist => 1})->[0];
2702 $self->push_events(OpenILS::Event->new(
2703 'ITEM_DEPOSIT_PAID', payload => $deposit)) if $deposit;
2708 my $force = $self->force || shift;
2709 my $copy = $self->copy;
2711 my $stat = $U->copy_status($copy->status)->id;
2714 $stat != OILS_COPY_STATUS_ON_HOLDS_SHELF and
2715 $stat != OILS_COPY_STATUS_CATALOGING and
2716 $stat != OILS_COPY_STATUS_IN_TRANSIT and
2717 $stat != OILS_COPY_STATUS_RESHELVING )) {
2719 $copy->status( OILS_COPY_STATUS_RESHELVING );
2721 $self->checkin_changed(1);
2726 # Returns true if the item is at the current location
2727 # because it was transited there for a hold and the
2728 # hold has not been fulfilled
2729 sub checkin_check_holds_shelf {
2731 return 0 unless $self->copy;
2734 $U->copy_status($self->copy->status)->id ==
2735 OILS_COPY_STATUS_ON_HOLDS_SHELF;
2737 # Attempt to clear shelf expired holds for this copy
2738 $holdcode->method_lookup('open-ils.circ.hold.clear_shelf.process')->run($self->editor->authtoken, $self->circ_lib, $self->copy->id)
2739 if($self->clear_expired);
2741 # find the hold that put us on the holds shelf
2742 my $holds = $self->editor->search_action_hold_request(
2744 current_copy => $self->copy->id,
2745 capture_time => { '!=' => undef },
2746 fulfillment_time => undef,
2747 cancel_time => undef,
2752 $logger->warn("circulator: copy is on-holds-shelf, but there is no hold - reshelving");
2753 $self->reshelve_copy(1);
2757 my $hold = $$holds[0];
2759 $logger->info("circulator: we found a captured, un-fulfilled hold [".
2760 $hold->id. "] for copy ".$self->copy->barcode);
2762 if( $hold->pickup_lib != $self->circ_lib and not $self->hold_as_transit ) {
2763 my $suppress_transit_circ = $U->ou_ancestor_setting($self->circ_lib, 'circ.transit.suppress_hold');
2764 if($suppress_transit_circ && $suppress_transit_circ->{value}) {
2765 my $suppress_transit_pickup = $U->ou_ancestor_setting($hold->pickup_lib, 'circ.transit.suppress_hold');
2766 if($suppress_transit_pickup && $suppress_transit_circ->{value} eq $suppress_transit_pickup->{value}) {
2767 $logger->info("circulator: hold is within hold transit suppress group .. we're done: ".$self->copy->barcode." ".$suppress_transit_circ->{value});
2768 $self->fake_hold_dest(1);
2774 if( $hold->pickup_lib == $self->circ_lib and not $self->hold_as_transit ) {
2775 $logger->info("circulator: hold is for here .. we're done: ".$self->copy->barcode);
2779 $logger->info("circulator: hold is not for here..");
2780 $self->remote_hold($hold);
2785 sub checkin_handle_precat {
2787 my $copy = $self->copy;
2789 if( $self->is_precat and ($copy->status != OILS_COPY_STATUS_CATALOGING) ) {
2790 $copy->status(OILS_COPY_STATUS_CATALOGING);
2791 $self->update_copy();
2792 $self->checkin_changed(1);
2793 $self->push_events(OpenILS::Event->new('ITEM_NOT_CATALOGED'));
2798 sub checkin_build_copy_transit {
2801 my $copy = $self->copy;
2802 my $transit = Fieldmapper::action::transit_copy->new;
2804 # if we are transiting an item to the shelf shelf, it's a hold transit
2805 if (my $hold = $self->remote_hold) {
2806 $transit = Fieldmapper::action::hold_transit_copy->new;
2807 $transit->hold($hold->id);
2809 # the item is going into transit, remove any shelf-iness
2810 if ($hold->current_shelf_lib or $hold->shelf_time) {
2811 $hold->clear_current_shelf_lib;
2812 $hold->clear_shelf_time;
2813 return $self->bail_on_events($self->editor->event)
2814 unless $self->editor->update_action_hold_request($hold);
2818 #$dest ||= (ref($copy->circ_lib)) ? $copy->circ_lib->id : $copy->circ_lib;
2819 $logger->info("circulator: transiting copy to $dest");
2821 $transit->source($self->circ_lib);
2822 $transit->dest($dest);
2823 $transit->target_copy($copy->id);
2824 $transit->source_send_time('now');
2825 $transit->copy_status( $U->copy_status($copy->status)->id );
2827 $logger->debug("circulator: setting copy status on transit: ".$transit->copy_status);
2829 if ($self->remote_hold) {
2830 return $self->bail_on_events($self->editor->event)
2831 unless $self->editor->create_action_hold_transit_copy($transit);
2833 return $self->bail_on_events($self->editor->event)
2834 unless $self->editor->create_action_transit_copy($transit);
2837 $copy->status(OILS_COPY_STATUS_IN_TRANSIT);
2839 $self->checkin_changed(1);
2843 sub hold_capture_is_possible {
2845 my $copy = $self->copy;
2847 # we've been explicitly told not to capture any holds
2848 return 0 if $self->capture eq 'nocapture';
2850 # See if this copy can fulfill any holds
2851 my $hold = $holdcode->find_nearest_permitted_hold(
2852 $self->editor, $copy, $self->editor->requestor, 1 # check_only
2854 return undef if ref $hold eq "HASH" and
2855 $hold->{"textcode"} eq "ACTION_HOLD_REQUEST_NOT_FOUND";
2859 sub reservation_capture_is_possible {
2861 my $copy = $self->copy;
2863 # we've been explicitly told not to capture any holds
2864 return 0 if $self->capture eq 'nocapture';
2866 my $booking_ses = OpenSRF::AppSession->connect("open-ils.booking");
2867 my $resv = $booking_ses->request(
2868 "open-ils.booking.reservations.could_capture",
2869 $self->editor->authtoken, $copy->barcode
2871 $booking_ses->disconnect;
2872 if (ref($resv) eq "HASH" and exists $resv->{"textcode"}) {
2873 $self->push_events($resv);
2879 # returns true if the item was used (or may potentially be used
2880 # in subsequent calls) to capture a hold.
2881 sub attempt_checkin_hold_capture {
2883 my $copy = $self->copy;
2885 # we've been explicitly told not to capture any holds
2886 return 0 if $self->capture eq 'nocapture';
2888 # See if this copy can fulfill any holds
2889 my ($hold, undef, $retarget) = $holdcode->find_nearest_permitted_hold(
2890 $self->editor, $copy, $self->editor->requestor );
2893 $logger->debug("circulator: no potential permitted".
2894 "holds found for copy ".$copy->barcode);
2898 if($self->capture ne 'capture') {
2899 # see if this item is in a hold-capture-delay location
2900 my $location = $self->copy->location;
2901 if(!ref($location)) {
2902 $location = $self->editor->retrieve_asset_copy_location($self->copy->location);
2903 $self->copy->location($location);
2905 if($U->is_true($location->hold_verify)) {
2906 $self->bail_on_events(
2907 OpenILS::Event->new('HOLD_CAPTURE_DELAYED', copy_location => $location));
2912 $self->retarget($retarget);
2914 $logger->info("circulator: found permitted hold ".$hold->id." for copy, capturing...");
2916 $hold->current_copy($copy->id);
2917 $hold->capture_time('now');
2918 $self->put_hold_on_shelf($hold)
2919 if $hold->pickup_lib == $self->circ_lib;
2921 # prevent DB errors caused by fetching
2922 # holds from storage, and updating through cstore
2923 $hold->clear_fulfillment_time;
2924 $hold->clear_fulfillment_staff;
2925 $hold->clear_fulfillment_lib;
2926 $hold->clear_expire_time;
2927 $hold->clear_cancel_time;
2928 $hold->clear_prev_check_time unless $hold->prev_check_time;
2930 $self->bail_on_events($self->editor->event)
2931 unless $self->editor->update_action_hold_request($hold);
2933 $self->checkin_changed(1);
2935 return 0 if $self->bail_out;
2937 my $suppress_transit = 0;
2938 if( $hold->pickup_lib != $self->circ_lib and not $self->hold_as_transit ) {
2939 my $suppress_transit_circ = $U->ou_ancestor_setting($self->circ_lib, 'circ.transit.suppress_hold');
2940 if($suppress_transit_circ && $suppress_transit_circ->{value}) {
2941 my $suppress_transit_pickup = $U->ou_ancestor_setting($hold->pickup_lib, 'circ.transit.suppress_hold');
2942 if($suppress_transit_pickup && $suppress_transit_circ->{value} eq $suppress_transit_pickup->{value}) {
2943 $suppress_transit = 1;
2944 $self->hold->pickup_lib($self->circ_lib);
2949 if( $suppress_transit or ( $hold->pickup_lib == $self->circ_lib && not $self->hold_as_transit ) ) {
2951 # This hold was captured in the correct location
2952 $copy->status(OILS_COPY_STATUS_ON_HOLDS_SHELF);
2953 $self->push_events(OpenILS::Event->new('SUCCESS'));
2955 #$self->do_hold_notify($hold->id);
2956 $self->notify_hold($hold->id);
2960 # Hold needs to be picked up elsewhere. Build a hold
2961 # transit and route the item.
2962 $self->checkin_build_hold_transit();
2963 $copy->status(OILS_COPY_STATUS_IN_TRANSIT);
2964 return 0 if $self->bail_out;
2965 $self->push_events(OpenILS::Event->new('ROUTE_ITEM', org => $hold->pickup_lib));
2968 # make sure we save the copy status
2973 sub attempt_checkin_reservation_capture {
2975 my $copy = $self->copy;
2977 # we've been explicitly told not to capture any holds
2978 return 0 if $self->capture eq 'nocapture';
2980 my $booking_ses = OpenSRF::AppSession->connect("open-ils.booking");
2981 my $evt = $booking_ses->request(
2982 "open-ils.booking.resources.capture_for_reservation",
2983 $self->editor->authtoken,
2985 1 # don't update copy - we probably have it locked
2987 $booking_ses->disconnect;
2989 if (ref($evt) ne "HASH" or not exists $evt->{"textcode"}) {
2991 "open-ils.booking.resources.capture_for_reservation " .
2992 "didn't return an event!"
2996 $evt->{"textcode"} eq "RESERVATION_NOT_FOUND" and
2997 $evt->{"payload"}->{"fail_cause"} eq "not-transferable"
2999 # not-transferable is an error event we'll pass on the user
3000 $logger->warn("reservation capture attempted against non-transferable item");
3001 $self->push_events($evt);
3003 } elsif ($evt->{"textcode"} eq "SUCCESS") {
3004 # Re-retrieve copy as reservation capture may have changed
3005 # its status and whatnot.
3007 "circulator: booking capture win on copy " . $self->copy->id
3009 if (my $new_copy_status = $evt->{"payload"}->{"new_copy_status"}) {
3011 "circulator: changing copy " . $self->copy->id .
3012 "'s status from " . $self->copy->status . " to " .
3015 $self->copy->status($new_copy_status);
3018 $self->reservation($evt->{"payload"}->{"reservation"});
3020 if (exists $evt->{"payload"}->{"transit"}) {
3024 "org" => $evt->{"payload"}->{"transit"}->dest
3028 $self->checkin_changed(1);
3032 # other results are treated as "nothing to capture"
3036 sub do_hold_notify {
3037 my( $self, $holdid ) = @_;
3039 my $e = new_editor(xact => 1);
3040 my $hold = $e->retrieve_action_hold_request($holdid) or return $e->die_event;
3042 my $ses = OpenSRF::AppSession->create('open-ils.trigger');
3043 $ses->request('open-ils.trigger.event.autocreate', 'hold.available', $hold, $hold->pickup_lib);
3045 $logger->info("circulator: running delayed hold notify process");
3047 # my $notifier = OpenILS::Application::Circ::HoldNotify->new(
3048 # hold_id => $holdid, editor => new_editor(requestor=>$self->editor->requestor));
3050 my $notifier = OpenILS::Application::Circ::HoldNotify->new(
3051 hold_id => $holdid, requestor => $self->editor->requestor);
3053 $logger->debug("circulator: built hold notifier");
3055 if(!$notifier->event) {
3057 $logger->info("circulator: attempt at sending hold notification for hold $holdid");
3059 my $stat = $notifier->send_email_notify;
3060 if( $stat == '1' ) {
3061 $logger->info("circulator: hold notify succeeded for hold $holdid");
3065 $logger->debug("circulator: * hold notify cancelled or failed for hold $holdid");
3068 $logger->info("circulator: Not sending hold notification since the patron has no email address");
3072 sub retarget_holds {
3074 $logger->info("circulator: retargeting holds @{$self->retarget} after opportunistic capture");
3075 my $ses = OpenSRF::AppSession->create('open-ils.storage');
3076 $ses->request('open-ils.storage.action.hold_request.copy_targeter', undef, $self->retarget);
3077 # no reason to wait for the return value
3081 sub checkin_build_hold_transit {
3084 my $copy = $self->copy;
3085 my $hold = $self->hold;
3086 my $trans = Fieldmapper::action::hold_transit_copy->new;
3088 $logger->debug("circulator: building hold transit for ".$copy->barcode);
3090 $trans->hold($hold->id);
3091 $trans->source($self->circ_lib);
3092 $trans->dest($hold->pickup_lib);
3093 $trans->source_send_time("now");
3094 $trans->target_copy($copy->id);
3096 # when the copy gets to its destination, it will recover
3097 # this status - put it onto the holds shelf
3098 $trans->copy_status(OILS_COPY_STATUS_ON_HOLDS_SHELF);
3100 return $self->bail_on_events($self->editor->event)
3101 unless $self->editor->create_action_hold_transit_copy($trans);
3106 sub process_received_transit {
3108 my $copy = $self->copy;
3109 my $copyid = $self->copy->id;
3111 my $status_name = $U->copy_status($copy->status)->name;
3112 $logger->debug("circulator: attempting transit receive on ".
3113 "copy $copyid. Copy status is $status_name");
3115 my $transit = $self->transit;
3117 # Check if we are in a transit suppress range
3118 my $suppress_transit = 0;
3119 if ( $transit->dest != $self->circ_lib and not ( $self->hold_as_transit and $transit->copy_status == OILS_COPY_STATUS_ON_HOLDS_SHELF ) ) {
3120 my $suppress_setting = ($transit->copy_status == OILS_COPY_STATUS_ON_HOLDS_SHELF ? 'circ.transit.suppress_hold' : 'circ.transit.suppress_non_hold');
3121 my $suppress_transit_circ = $U->ou_ancestor_setting($self->circ_lib, $suppress_setting);
3122 if($suppress_transit_circ && $suppress_transit_circ->{value}) {
3123 my $suppress_transit_dest = $U->ou_ancestor_setting($transit->dest, $suppress_setting);
3124 if($suppress_transit_dest && $suppress_transit_dest->{value} eq $suppress_transit_circ->{value}) {
3125 $suppress_transit = 1;
3126 $self->fake_hold_dest(1) if $transit->copy_status == OILS_COPY_STATUS_ON_HOLDS_SHELF;
3130 if( not $suppress_transit and ( $transit->dest != $self->circ_lib or ($self->hold_as_transit && $transit->copy_status == OILS_COPY_STATUS_ON_HOLDS_SHELF) ) ) {
3131 # - this item is in-transit to a different location
3132 # - Or we are capturing holds as transits, so why create a new transit?
3134 my $tid = $transit->id;
3135 my $loc = $self->circ_lib;
3136 my $dest = $transit->dest;
3138 $logger->info("circulator: Fowarding transit on copy which is destined ".
3139 "for a different location. transit=$tid, copy=$copyid, current ".
3140 "location=$loc, destination location=$dest");
3142 my $evt = OpenILS::Event->new('ROUTE_ITEM', org => $dest, payload => {});
3144 # grab the associated hold object if available
3145 my $ht = $self->editor->retrieve_action_hold_transit_copy($tid);
3146 $self->hold($self->editor->retrieve_action_hold_request($ht->hold)) if $ht;
3148 return $self->bail_on_events($evt);
3151 # The transit is received, set the receive time
3152 $transit->dest_recv_time('now');
3153 $self->bail_on_events($self->editor->event)
3154 unless $self->editor->update_action_transit_copy($transit);
3156 my $hold_transit = $self->editor->retrieve_action_hold_transit_copy($transit->id);
3158 $logger->info("circulator: Recovering original copy status in transit: ".$transit->copy_status);
3159 $copy->status( $transit->copy_status );
3160 $self->update_copy();
3161 return if $self->bail_out;
3165 my $hold = $self->editor->retrieve_action_hold_request($hold_transit->hold);
3167 # hold has arrived at destination, set shelf time
3168 $self->put_hold_on_shelf($hold);
3169 $self->bail_on_events($self->editor->event)
3170 unless $self->editor->update_action_hold_request($hold);
3171 return if $self->bail_out;
3173 $self->notify_hold($hold_transit->hold);
3178 OpenILS::Event->new(
3181 payload => { transit => $transit, holdtransit => $hold_transit } ));
3183 return $hold_transit;
3187 # ------------------------------------------------------------------
3188 # Sets the shelf_time and shelf_expire_time for a newly shelved hold
3189 # ------------------------------------------------------------------
3190 sub put_hold_on_shelf {
3191 my($self, $hold) = @_;
3193 $hold->shelf_time('now');
3194 $hold->current_shelf_lib($self->circ_lib);
3196 my $shelf_expire = $U->ou_ancestor_setting_value(
3197 $self->circ_lib, 'circ.holds.default_shelf_expire_interval', $self->editor);
3199 return undef unless $shelf_expire;
3201 my $seconds = OpenSRF::Utils->interval_to_seconds($shelf_expire);
3202 my $expire_time = DateTime->now->add(seconds => $seconds);
3204 # if the shelf expire time overlaps with a pickup lib's
3205 # closed date, push it out to the first open date
3206 my $dateinfo = $U->storagereq(
3207 'open-ils.storage.actor.org_unit.closed_date.overlap',
3208 $hold->pickup_lib, $expire_time);
3211 my $dt_parser = DateTime::Format::ISO8601->new;
3212 $expire_time = $dt_parser->parse_datetime(cleanse_ISO8601($dateinfo->{end}));
3214 # TODO: enable/disable time bump via setting?
3215 $expire_time->set(hour => '23', minute => '59', second => '59');
3217 $logger->info("circulator: shelf_expire_time overlaps".
3218 " with closed date, pushing expire time to $expire_time");
3221 $hold->shelf_expire_time($expire_time->strftime('%FT%T%z'));
3227 sub generate_fines {
3229 my $reservation = shift;
3231 $self->generate_fines_start($reservation);
3232 $self->generate_fines_finish($reservation);
3237 sub generate_fines_start {
3239 my $reservation = shift;
3240 my $dt_parser = DateTime::Format::ISO8601->new;
3242 my $obj = $reservation ? $self->reservation : $self->circ;
3244 # If we have a grace period
3245 if($obj->can('grace_period')) {
3246 # Parse out the due date
3247 my $due_date = $dt_parser->parse_datetime( cleanse_ISO8601($obj->due_date) );
3248 # Add the grace period to the due date
3249 $due_date->add(seconds => OpenSRF::Utils->interval_to_seconds($obj->grace_period));
3250 # Don't generate fines on circs still in grace period
3251 return undef if ($due_date > DateTime->now);
3254 if (!exists($self->{_gen_fines_req})) {
3255 $self->{_gen_fines_req} = OpenSRF::AppSession->create('open-ils.storage')
3257 'open-ils.storage.action.circulation.overdue.generate_fines',
3265 sub generate_fines_finish {
3267 my $reservation = shift;
3269 return undef unless $self->{_gen_fines_req};
3271 my $id = $reservation ? $self->reservation->id : $self->circ->id;
3273 $self->{_gen_fines_req}->wait_complete;
3274 delete($self->{_gen_fines_req});
3276 # refresh the circ in case the fine generator set the stop_fines field
3277 $self->reservation($self->editor->retrieve_booking_reservation($id)) if $reservation;
3278 $self->circ($self->editor->retrieve_action_circulation($id)) if !$reservation;
3283 sub checkin_handle_circ {
3285 my $circ = $self->circ;
3286 my $copy = $self->copy;
3290 $self->backdate($circ->xact_start) if $self->claims_never_checked_out;
3292 # backdate the circ if necessary
3293 if($self->backdate) {
3294 my $evt = $self->checkin_handle_backdate;
3295 return $self->bail_on_events($evt) if $evt;
3298 if(!$circ->stop_fines) {
3299 $circ->stop_fines(OILS_STOP_FINES_CHECKIN);
3300 $circ->stop_fines(OILS_STOP_FINES_RENEW) if $self->is_renewal;
3301 $circ->stop_fines(OILS_STOP_FINES_CLAIMS_NEVERCHECKEDOUT) if $self->claims_never_checked_out;
3302 $circ->stop_fines_time('now');
3303 $circ->stop_fines_time($self->backdate) if $self->backdate;
3306 # Set the checkin vars since we have the item
3307 $circ->checkin_time( ($self->backdate) ? $self->backdate : 'now' );
3309 # capture the true scan time for back-dated checkins
3310 $circ->checkin_scan_time('now');
3312 $circ->checkin_staff($self->editor->requestor->id);
3313 $circ->checkin_lib($self->circ_lib);
3314 $circ->checkin_workstation($self->editor->requestor->wsid);
3316 my $circ_lib = (ref $self->copy->circ_lib) ?
3317 $self->copy->circ_lib->id : $self->copy->circ_lib;
3318 my $stat = $U->copy_status($self->copy->status)->id;
3320 if ($stat == OILS_COPY_STATUS_LOST) {
3321 # we will now handle lost fines, but the copy will retain its 'lost'
3322 # status if it needs to transit home unless lost_immediately_available
3325 # if we decide to also delay fine handling until the item arrives home,
3326 # we will need to call lost fine handling code both when checking items
3327 # in and also when receiving transits
3328 $self->checkin_handle_lost($circ_lib);
3329 } elsif ($circ_lib != $self->circ_lib and $stat == OILS_COPY_STATUS_MISSING) {
3330 $logger->info("circulator: not updating copy status on checkin because copy is missing");
3332 $self->copy->status($U->copy_status(OILS_COPY_STATUS_RESHELVING));
3337 # see if there are any fines owed on this circ. if not, close it
3338 ($obt) = $U->fetch_mbts($circ->id, $self->editor);
3339 $circ->xact_finish('now') if( $obt and $obt->balance_owed == 0 );
3341 $logger->debug("circulator: ".$obt->balance_owed." is owed on this circulation");
3343 return $self->bail_on_events($self->editor->event)
3344 unless $self->editor->update_action_circulation($circ);
3350 # ------------------------------------------------------------------
3351 # See if we need to void billings for lost checkin
3352 # ------------------------------------------------------------------
3353 sub checkin_handle_lost {
3355 my $circ_lib = shift;
3356 my $circ = $self->circ;
3358 my $max_return = $U->ou_ancestor_setting_value(
3359 $circ_lib, OILS_SETTING_MAX_ACCEPT_RETURN_OF_LOST, $self->editor) || 0;
3364 my @tm = reverse($circ->due_date =~ /([\d\.]+)/og);
3365 $tm[5] -= 1 if $tm[5] > 0;
3366 my $due = timelocal(int($tm[1]), int($tm[2]), int($tm[3]), int($tm[4]), int($tm[5]), int($tm[6]));
3368 my $last_chance = OpenSRF::Utils->interval_to_seconds($max_return) + int($due);
3369 $logger->info("MAX OD: ".$max_return." DUEDATE: ".$circ->due_date." TODAY: ".$today." DUE: ".$due." LAST: ".$last_chance);
3371 $max_return = 0 if $today < $last_chance;
3374 if (!$max_return){ # there's either no max time to accept returns defined or we're within that time
3376 my $void_lost = $U->ou_ancestor_setting_value(
3377 $circ_lib, OILS_SETTING_VOID_LOST_ON_CHECKIN, $self->editor) || 0;
3378 my $void_lost_fee = $U->ou_ancestor_setting_value(
3379 $circ_lib, OILS_SETTING_VOID_LOST_PROCESS_FEE_ON_CHECKIN, $self->editor) || 0;
3380 my $restore_od = $U->ou_ancestor_setting_value(
3381 $circ_lib, OILS_SETTING_RESTORE_OVERDUE_ON_LOST_RETURN, $self->editor) || 0;
3382 $self->generate_lost_overdue(1) if $U->ou_ancestor_setting_value(
3383 $circ_lib, OILS_SETTING_GENERATE_OVERDUE_ON_LOST_RETURN, $self->editor);
3385 $self->checkin_handle_lost_now_found(3) if $void_lost;
3386 $self->checkin_handle_lost_now_found(4) if $void_lost_fee;
3387 $self->checkin_handle_lost_now_found_restore_od($circ_lib) if $restore_od && ! $self->void_overdues;
3390 if ($circ_lib != $self->circ_lib) {
3391 # if the item is not home, check to see if we want to retain the lost
3392 # status at this point in the process
3393 my $immediately_available = $U->ou_ancestor_setting_value($circ_lib, OILS_SETTING_LOST_IMMEDIATELY_AVAILABLE, $self->editor) || 0;
3395 if ($immediately_available) {
3396 # lost item status does not need to be retained, so give it a
3397 # reshelving status as if it were a normal checkin
3398 $self->copy->status($U->copy_status(OILS_COPY_STATUS_RESHELVING));
3401 $logger->info("circulator: not updating copy status on checkin because copy is lost");
3404 # lost item is home and processed, treat like a normal checkin from
3406 $self->copy->status($U->copy_status(OILS_COPY_STATUS_RESHELVING));
3412 sub checkin_handle_backdate {
3415 # ------------------------------------------------------------------
3416 # clean up the backdate for date comparison
3417 # XXX We are currently taking the due-time from the original due-date,
3418 # not the input. Do we need to do this? This certainly interferes with
3419 # backdating of hourly checkouts, but that is likely a very rare case.
3420 # ------------------------------------------------------------------
3421 my $bd = cleanse_ISO8601($self->backdate);
3422 my $original_date = DateTime::Format::ISO8601->new->parse_datetime(cleanse_ISO8601($self->circ->due_date));
3423 my $new_date = DateTime::Format::ISO8601->new->parse_datetime($bd);
3424 $bd = cleanse_ISO8601($new_date->ymd . 'T' . $original_date->strftime('%T%z'));
3426 $self->backdate($bd);
3431 sub check_checkin_copy_status {
3433 my $copy = $self->copy;
3435 my $status = $U->copy_status($copy->status)->id;
3438 if( $status == OILS_COPY_STATUS_AVAILABLE ||
3439 $status == OILS_COPY_STATUS_CHECKED_OUT ||
3440 $status == OILS_COPY_STATUS_IN_PROCESS ||
3441 $status == OILS_COPY_STATUS_ON_HOLDS_SHELF ||
3442 $status == OILS_COPY_STATUS_IN_TRANSIT ||
3443 $status == OILS_COPY_STATUS_CATALOGING ||
3444 $status == OILS_COPY_STATUS_ON_RESV_SHELF ||
3445 $status == OILS_COPY_STATUS_RESHELVING );
3447 return OpenILS::Event->new('COPY_STATUS_LOST', payload => $copy )
3448 if( $status == OILS_COPY_STATUS_LOST );
3450 return OpenILS::Event->new('COPY_STATUS_MISSING', payload => $copy )
3451 if( $status == OILS_COPY_STATUS_MISSING );
3453 return OpenILS::Event->new('COPY_BAD_STATUS', payload => $copy );
3458 # --------------------------------------------------------------------------
3459 # On checkin, we need to return as many relevant objects as we can
3460 # --------------------------------------------------------------------------
3461 sub checkin_flesh_events {
3464 if( grep { $_->{textcode} eq 'SUCCESS' } @{$self->events}
3465 and grep { $_->{textcode} eq 'ITEM_NOT_CATALOGED' } @{$self->events} ) {
3466 $self->events([grep { $_->{textcode} eq 'ITEM_NOT_CATALOGED' } @{$self->events}]);
3469 my $record = $U->record_to_mvr($self->title) if($self->title and !$self->is_precat);
3472 if($self->hold and !$self->hold->cancel_time) {
3473 $hold = $self->hold;
3474 $hold->notes($self->editor->search_action_hold_request_note({hold => $hold->id}));
3478 # if we checked in a circulation, flesh the billing summary data
3479 $self->circ->billable_transaction(
3480 $self->editor->retrieve_money_billable_transaction([
3482 {flesh => 1, flesh_fields => {mbt => ['summary']}}
3488 # flesh some patron fields before returning
3490 $self->editor->retrieve_actor_user([
3495 au => ['card', 'billing_address', 'mailing_address']
3502 for my $evt (@{$self->events}) {
3505 $payload->{copy} = $U->unflesh_copy($self->copy);
3506 $payload->{volume} = $self->volume;
3507 $payload->{record} = $record,
3508 $payload->{circ} = $self->circ;
3509 $payload->{transit} = $self->transit;
3510 $payload->{cancelled_hold_transit} = 1 if $self->cancelled_hold_transit;
3511 $payload->{hold} = $hold;
3512 $payload->{patron} = $self->patron;
3513 $payload->{reservation} = $self->reservation
3514 unless (not $self->reservation or $self->reservation->cancel_time);
3516 $evt->{payload} = $payload;
3521 my( $self, $msg ) = @_;
3522 my $bc = ($self->copy) ? $self->copy->barcode :
3525 my $usr = ($self->patron) ? $self->patron->id : "";
3526 $logger->info("circulator: $msg requestor=".$self->editor->requestor->id.
3527 ", recipient=$usr, copy=$bc");
3533 $self->log_me("do_renew()");
3535 # Make sure there is an open circ to renew that is not
3536 # marked as LOST, CLAIMSRETURNED, or LONGOVERDUE
3537 my $usrid = $self->patron->id if $self->patron;
3538 my $circ = $self->editor->search_action_circulation({
3539 target_copy => $self->copy->id,
3540 xact_finish => undef,
3541 checkin_time => undef,
3542 ($usrid ? (usr => $usrid) : ()),
3544 {stop_fines => undef},
3545 {stop_fines => OILS_STOP_FINES_MAX_FINES}
3549 return $self->bail_on_events($self->editor->event) unless $circ;
3551 # A user is not allowed to renew another user's items without permission
3552 unless( $circ->usr eq $self->editor->requestor->id ) {
3553 return $self->bail_on_events($self->editor->events)
3554 unless $self->editor->allowed('RENEW_CIRC', $circ->circ_lib);
3557 $self->push_events(OpenILS::Event->new('MAX_RENEWALS_REACHED'))
3558 if $circ->renewal_remaining < 1;
3560 # -----------------------------------------------------------------
3562 $self->parent_circ($circ->id);
3563 $self->renewal_remaining( $circ->renewal_remaining - 1 );
3566 # Opac renewal - re-use circ library from original circ (unless told not to)
3567 if($self->opac_renewal) {
3568 unless(defined($opac_renewal_use_circ_lib)) {
3569 my $use_circ_lib = $self->editor->retrieve_config_global_flag('circ.opac_renewal.use_original_circ_lib');
3570 if($use_circ_lib and $U->is_true($use_circ_lib->enabled)) {
3571 $opac_renewal_use_circ_lib = 1;
3574 $opac_renewal_use_circ_lib = 0;
3577 $self->circ_lib($circ->circ_lib) if($opac_renewal_use_circ_lib);
3580 # Run the fine generator against the old circ
3581 $self->generate_fines_start;
3583 $self->run_renew_permit;
3586 $self->do_checkin();
3587 return if $self->bail_out;
3589 unless( $self->permit_override ) {
3591 return if $self->bail_out;
3592 $self->is_precat(1) if $self->have_event('ITEM_NOT_CATALOGED');
3593 $self->remove_event('ITEM_NOT_CATALOGED');
3596 $self->override_events;
3597 return if $self->bail_out;
3600 $self->do_checkout();
3605 my( $self, $evt ) = @_;
3606 $evt = (ref $evt) ? $evt->{textcode} : $evt;
3607 $logger->debug("circulator: removing event from list: $evt");
3608 my @events = @{$self->events};
3609 $self->events( [ grep { $_->{textcode} ne $evt } @events ] );
3614 my( $self, $evt ) = @_;
3615 $evt = (ref $evt) ? $evt->{textcode} : $evt;
3616 return grep { $_->{textcode} eq $evt } @{$self->events};
3621 sub run_renew_permit {
3624 if ($U->ou_ancestor_setting_value($self->circ_lib, 'circ.block_renews_for_holds')) {
3625 my ($hold, undef, $retarget) = $holdcode->find_nearest_permitted_hold(
3626 $self->editor, $self->copy, $self->editor->requestor, 1
3628 $self->push_events(new OpenILS::Event("COPY_NEEDED_FOR_HOLD")) if $hold;
3631 if(!$self->legacy_script_support) {
3632 my $results = $self->run_indb_circ_test;
3633 $self->push_events($self->matrix_test_result_events)
3634 unless $self->circ_test_success;
3637 my $runner = $self->script_runner;
3639 $runner->load($self->circ_permit_renew);
3640 my $result = $runner->run or
3641 throw OpenSRF::EX::ERROR ("Circ Permit Renew Script Died: $@");
3642 if ($result->{"events"}) {
3644 map { new OpenILS::Event($_) } @{$result->{"events"}}
3647 "circulator: circ_permit_renew for user " .
3648 $self->patron->id . " returned " .
3649 scalar(@{$result->{"events"}}) . " event(s)"
3653 $self->mk_script_runner;
3656 $logger->debug("circulator: re-creating script runner to be safe");
3660 # XXX: The primary mechanism for storing circ history is now handled
3661 # by tracking real circulation objects instead of bibs in a bucket.
3662 # However, this code is disabled by default and could be useful
3663 # some day, so may as well leave it for now.
3664 sub append_reading_list {
3668 $self->is_checkout and
3674 # verify history is globally enabled and uses the bucket mechanism
3675 my $htype = OpenSRF::Utils::SettingsClient->new->config_value(
3676 apps => 'open-ils.circ' => app_settings => 'checkout_history_mechanism');
3678 return undef unless $htype and $htype eq 'bucket';
3680 my $e = new_editor(xact => 1, requestor => $self->editor->requestor);
3682 # verify the patron wants to retain the hisory
3683 my $setting = $e->search_actor_user_setting(
3684 {usr => $self->patron->id, name => 'circ.keep_checkout_history'})->[0];
3686 unless($setting and $setting->value) {
3691 my $bkt = $e->search_container_copy_bucket(
3692 {owner => $self->patron->id, btype => 'circ_history'})->[0];
3697 # find the next item position
3698 my $last_item = $e->search_container_copy_bucket_item(
3699 {bucket => $bkt->id}, {order_by => {ccbi => 'pos desc'}, limit => 1})->[0];
3700 $pos = $last_item->pos + 1 if $last_item;
3703 # create the history bucket if necessary
3704 $bkt = Fieldmapper::container::copy_bucket->new;
3705 $bkt->owner($self->patron->id);
3707 $bkt->btype('circ_history');
3709 $e->create_container_copy_bucket($bkt) or return $e->die_event;
3712 my $item = Fieldmapper::container::copy_bucket_item->new;
3714 $item->bucket($bkt->id);
3715 $item->target_copy($self->copy->id);
3718 $e->create_container_copy_bucket_item($item) or return $e->die_event;
3725 sub make_trigger_events {
3727 return unless $self->circ;
3728 $U->create_events_for_hook('checkout', $self->circ, $self->circ_lib) if $self->is_checkout;
3729 $U->create_events_for_hook('checkin', $self->circ, $self->circ_lib) if $self->is_checkin;
3730 $U->create_events_for_hook('renewal', $self->circ, $self->circ_lib) if $self->is_renewal;
3735 sub checkin_handle_lost_now_found {
3736 my ($self, $bill_type) = @_;
3738 # ------------------------------------------------------------------
3739 # remove charge from patron's account if lost item is returned
3740 # ------------------------------------------------------------------
3742 my $bills = $self->editor->search_money_billing(
3744 xact => $self->circ->id,
3749 $logger->debug("voiding lost item charge of ".scalar(@$bills));
3750 for my $bill (@$bills) {
3751 if( !$U->is_true($bill->voided) ) {
3752 $logger->info("lost item returned - voiding bill ".$bill->id);
3754 $bill->void_time('now');
3755 $bill->voider($self->editor->requestor->id);
3756 my $note = ($bill->note) ? $bill->note . "\n" : '';
3757 $bill->note("${note}System: VOIDED FOR LOST ITEM RETURNED");
3759 $self->bail_on_events($self->editor->event)
3760 unless $self->editor->update_money_billing($bill);
3765 sub checkin_handle_lost_now_found_restore_od {
3767 my $circ_lib = shift;
3769 # ------------------------------------------------------------------
3770 # restore those overdue charges voided when item was set to lost
3771 # ------------------------------------------------------------------
3773 my $ods = $self->editor->search_money_billing(
3775 xact => $self->circ->id,
3780 $logger->debug("returning overdue charges pre-lost ".scalar(@$ods));
3781 for my $bill (@$ods) {
3782 if( $U->is_true($bill->voided) ) {
3783 $logger->info("lost item returned - restoring overdue ".$bill->id);
3785 $bill->clear_void_time;
3786 $bill->voider($self->editor->requestor->id);
3787 my $note = ($bill->note) ? $bill->note . "\n" : '';
3788 $bill->note("${note}System: LOST RETURNED - OVERDUES REINSTATED");
3790 $self->bail_on_events($self->editor->event)
3791 unless $self->editor->update_money_billing($bill);
3796 # ------------------------------------------------------------------
3797 # Lost-then-found item checked in. This sub generates new overdue
3798 # fines, beyond the point of any existing and possibly voided
3799 # overdue fines, up to the point of final checkin time (or max fine
3801 # ------------------------------------------------------------------
3802 sub generate_lost_overdue_fines {
3804 my $circ = $self->circ;
3805 my $e = $self->editor;
3807 # Re-open the transaction so the fine generator can see it
3808 if($circ->xact_finish or $circ->stop_fines) {
3810 $circ->clear_xact_finish;
3811 $circ->clear_stop_fines;
3812 $circ->clear_stop_fines_time;
3813 $e->update_action_circulation($circ) or return $e->die_event;
3817 $e->xact_begin; # generate_fines expects an in-xact editor
3818 $self->generate_fines;
3819 $circ = $self->circ; # generate fines re-fetches the circ
3823 # Re-close the transaction if no money is owed
3824 my ($obt) = $U->fetch_mbts($circ->id, $e);
3825 if ($obt and $obt->balance_owed == 0) {
3826 $circ->xact_finish('now');
3830 # Set stop fines if the fine generator didn't have to
3831 unless($circ->stop_fines) {
3832 $circ->stop_fines(OILS_STOP_FINES_CHECKIN);
3833 $circ->stop_fines_time('now');
3837 # update the event data sent to the caller within the transaction
3838 $self->checkin_flesh_events;
3841 $e->update_action_circulation($circ) or return $e->die_event;