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 OpenSRF::Utils::Config;
9 use OpenILS::Utils::CStoreEditor qw/:funcs/;
10 use OpenILS::Const qw/:const/;
11 use OpenILS::Application::AppUtils;
13 my $U = "OpenILS::Application::AppUtils";
17 my $opac_renewal_use_circ_lib;
18 my $desk_renewal_use_circ_lib;
20 sub determine_booking_status {
21 unless (defined $booking_status) {
22 my $ses = create OpenSRF::AppSession("router");
23 $booking_status = grep {$_ eq "open-ils.booking"} @{
24 $ses->request("opensrf.router.info.class.list")->gather(1)
27 $logger->info("booking status: " . ($booking_status ? "on" : "off"));
30 return $booking_status;
36 flesh_fields => {acp => ['call_number','parts','floating'], acn => ['record']}
39 # table of cases where suppressing a system-generated copy alerts
40 # should generate an override of an old-style event
41 my %COPY_ALERT_OVERRIDES = (
42 "CLAIMSRETURNED\tCHECKOUT" => ['CIRC_CLAIMS_RETURNED'],
43 "CLAIMSRETURNED\tCHECKIN" => ['CIRC_CLAIMS_RETURNED'],
44 "LOST\tCHECKOUT" => ['OPEN_CIRCULATION_EXISTS'],
45 "LONGOVERDUE\tCHECKOUT" => ['OPEN_CIRCULATION_EXISTS'],
46 "MISSING\tCHECKOUT" => ['COPY_NOT_AVAILABLE'],
47 "DAMAGED\tCHECKOUT" => ['COPY_NOT_AVAILABLE'],
48 "LOST_AND_PAID\tCHECKOUT" => ['COPY_NOT_AVAILABLE', 'OPEN_CIRCULATION_EXISTS']
53 __PACKAGE__->register_method(
54 method => "run_method",
55 api_name => "open-ils.circ.checkout.permit",
57 Determines if the given checkout can occur
58 @param authtoken The login session key
59 @param params A trailing hash of named params including
60 barcode : The copy barcode,
61 patron : The patron the checkout is occurring for,
62 renew : true or false - whether or not this is a renewal
63 @return The event that occurred during the permit check.
67 __PACKAGE__->register_method (
68 method => 'run_method',
69 api_name => 'open-ils.circ.checkout.permit.override',
70 signature => q/@see open-ils.circ.checkout.permit/,
74 __PACKAGE__->register_method(
75 method => "run_method",
76 api_name => "open-ils.circ.checkout",
79 @param authtoken The login session key
80 @param params A named hash of params including:
82 barcode If no copy is provided, the copy is retrieved via barcode
83 copyid If no copy or barcode is provide, the copy id will be use
84 patron The patron's id
85 noncat True if this is a circulation for a non-cataloted item
86 noncat_type The non-cataloged type id
87 noncat_circ_lib The location for the noncat circ.
88 precat The item has yet to be cataloged
89 dummy_title The temporary title of the pre-cataloded item
90 dummy_author The temporary authr of the pre-cataloded item
91 Default is the home org of the staff member
92 @return The SUCCESS event on success, any other event depending on the error
95 __PACKAGE__->register_method(
96 method => "run_method",
97 api_name => "open-ils.circ.checkin",
100 Generic super-method for handling all copies
101 @param authtoken The login session key
102 @param params Hash of named parameters including:
103 barcode - The copy barcode
104 force - If true, copies in bad statuses will be checked in and give good statuses
105 noop - don't capture holds or put items into transit
106 void_overdues - void all overdues for the circulation (aka amnesty)
111 __PACKAGE__->register_method(
112 method => "run_method",
113 api_name => "open-ils.circ.checkin.override",
114 signature => q/@see open-ils.circ.checkin/
117 __PACKAGE__->register_method(
118 method => "run_method",
119 api_name => "open-ils.circ.renew.override",
120 signature => q/@see open-ils.circ.renew/,
123 __PACKAGE__->register_method(
124 method => "run_method",
125 api_name => "open-ils.circ.renew",
126 notes => <<" NOTES");
127 PARAMS( authtoken, circ => circ_id );
128 open-ils.circ.renew(login_session, circ_object);
129 Renews the provided circulation. login_session is the requestor of the
130 renewal and if the logged in user is not the same as circ->usr, then
131 the logged in user must have RENEW_CIRC permissions.
134 __PACKAGE__->register_method(
135 method => "run_method",
136 api_name => "open-ils.circ.checkout.full"
138 __PACKAGE__->register_method(
139 method => "run_method",
140 api_name => "open-ils.circ.checkout.full.override"
142 __PACKAGE__->register_method(
143 method => "run_method",
144 api_name => "open-ils.circ.reservation.pickup"
146 __PACKAGE__->register_method(
147 method => "run_method",
148 api_name => "open-ils.circ.reservation.return"
150 __PACKAGE__->register_method(
151 method => "run_method",
152 api_name => "open-ils.circ.reservation.return.override"
154 __PACKAGE__->register_method(
155 method => "run_method",
156 api_name => "open-ils.circ.checkout.inspect",
157 desc => q/Returns the circ matrix test result and, on success, the rule set and matrix test object/
162 my( $self, $conn, $auth, $args ) = @_;
163 translate_legacy_args($args);
164 $args->{override_args} = { all => 1 } unless defined $args->{override_args};
165 $args->{new_copy_alerts} ||= $self->api_level > 1 ? 1 : 0;
166 my $api = $self->api_name;
169 OpenILS::Application::Circ::Circulator->new($auth, %$args);
171 return circ_events($circulator) if $circulator->bail_out;
173 $circulator->use_booking(determine_booking_status());
175 # --------------------------------------------------------------------------
176 # First, check for a booking transit, as the barcode may not be a copy
177 # barcode, but a resource barcode, and nothing else in here will work
178 # --------------------------------------------------------------------------
180 if ($circulator->use_booking && (my $bc = $circulator->copy_barcode) && $api !~ /checkout|inspect/) { # do we have a barcode?
181 my $resources = $circulator->editor->search_booking_resource( { barcode => $bc } ); # any resources by this barcode?
182 if (@$resources) { # yes!
184 my $res_id_list = [ map { $_->id } @$resources ];
185 my $transit = $circulator->editor->search_action_reservation_transit_copy(
187 { target_copy => $res_id_list, dest => $circulator->circ_lib, dest_recv_time => undef, cancel_time => undef },
188 { order_by => { artc => 'source_send_time' }, limit => 1 }
190 )->[0]; # Any transit for this barcode?
192 if ($transit) { # yes! unwrap it.
194 my $reservation = $circulator->editor->retrieve_booking_reservation( $transit->reservation );
195 my $res_type = $circulator->editor->retrieve_booking_resource_type( $reservation->target_resource_type );
197 my $success_event = new OpenILS::Event(
198 "SUCCESS", "payload" => {"reservation" => $reservation}
200 if ($U->is_true($res_type->catalog_item)) { # is there a copy to be had here?
201 if (my $copy = $circulator->editor->search_asset_copy([
202 { barcode => $bc, deleted => 'f' }, $MK_ENV_FLESH
203 ])->[0]) { # got a copy
204 $copy->status( $transit->copy_status );
205 $copy->editor($circulator->editor->requestor->id);
206 $copy->edit_date('now');
207 $circulator->editor->update_asset_copy($copy);
208 $success_event->{"payload"}->{"record"} =
209 $U->record_to_mvr($copy->call_number->record);
210 $success_event->{"payload"}->{"volume"} = $copy->call_number;
211 $copy->call_number($copy->call_number->id);
212 $success_event->{"payload"}->{"copy"} = $copy;
216 $transit->dest_recv_time('now');
217 $circulator->editor->update_action_reservation_transit_copy( $transit );
219 $circulator->editor->commit;
220 # Formerly this branch just stopped here. Argh!
221 $conn->respond_complete($success_event);
227 if ($circulator->use_booking) {
228 $circulator->is_res_checkin($circulator->is_checkin(1))
229 if $api =~ /reservation.return/ or (
230 $api =~ /checkin/ and $circulator->seems_like_reservation()
233 $circulator->is_res_checkout(1) if $api =~ /reservation.pickup/;
236 $circulator->is_renewal(1) if $api =~ /renew/;
237 $circulator->is_checkin(1) if $api =~ /checkin/;
238 $circulator->is_checkout(1) if $api =~ /checkout/;
239 $circulator->override(1) if $api =~ /override/o;
241 $circulator->mk_env();
242 $circulator->noop(1) if $circulator->claims_never_checked_out;
244 return circ_events($circulator) if $circulator->bail_out;
246 if( $api =~ /checkout\.permit/ ) {
247 $circulator->do_permit();
249 } elsif( $api =~ /checkout.full/ ) {
251 # requesting a precat checkout implies that any required
252 # overrides have been performed. Go ahead and re-override.
253 $circulator->skip_permit_key(1);
254 $circulator->override(1) if ( $circulator->request_precat && $circulator->editor->allowed('CREATE_PRECAT') );
255 $circulator->do_permit();
256 $circulator->is_checkout(1);
257 unless( $circulator->bail_out ) {
258 $circulator->events([]);
259 $circulator->do_checkout();
262 } elsif( $circulator->is_res_checkout ) {
263 $circulator->do_reservation_pickup();
265 } elsif( $api =~ /inspect/ ) {
266 my $data = $circulator->do_inspect();
267 $circulator->editor->rollback;
270 } elsif( $api =~ /checkout/ ) {
271 $circulator->do_checkout();
273 } elsif( $circulator->is_res_checkin ) {
274 $circulator->do_reservation_return();
275 $circulator->do_checkin() if ($circulator->copy());
276 } elsif( $api =~ /checkin/ ) {
277 $circulator->do_checkin();
279 } elsif( $api =~ /renew/ ) {
280 $circulator->do_renew($api);
283 if( $circulator->bail_out ) {
286 # make sure no success event accidentally slip in
288 [ grep { $_->{textcode} ne 'SUCCESS' } @{$circulator->events} ]);
291 my @e = @{$circulator->events};
292 push( @ee, $_->{textcode} ) for @e;
293 $logger->info("circulator: bailing out with events: " . (join ", ", @ee));
295 $circulator->editor->rollback;
299 # checkin and reservation return can result in modifications to
300 # actor.usr.claims_never_checked_out_count without also modifying
301 # actor.last_xact_id. Perform a no-op update on the patron to
302 # force an update to last_xact_id.
303 if ($circulator->claims_never_checked_out && $circulator->patron) {
304 $circulator->editor->update_actor_user(
305 $circulator->editor->retrieve_actor_user($circulator->patron->id))
306 or return $circulator->editor->die_event;
309 $circulator->editor->commit;
312 $conn->respond_complete(circ_events($circulator));
314 return undef if $circulator->bail_out;
316 $circulator->do_hold_notify($circulator->notify_hold)
317 if $circulator->notify_hold;
318 $circulator->retarget_holds if $circulator->retarget;
319 $circulator->append_reading_list;
320 $circulator->make_trigger_events;
327 my @e = @{$circ->events};
328 # if we have multiple events, SUCCESS should not be one of them;
329 @e = grep { $_->{textcode} ne 'SUCCESS' } @e if @e > 1;
330 return (@e == 1) ? $e[0] : \@e;
334 sub translate_legacy_args {
337 if( $$args{barcode} ) {
338 $$args{copy_barcode} = $$args{barcode};
339 delete $$args{barcode};
342 if( $$args{copyid} ) {
343 $$args{copy_id} = $$args{copyid};
344 delete $$args{copyid};
347 if( $$args{patronid} ) {
348 $$args{patron_id} = $$args{patronid};
349 delete $$args{patronid};
352 if( $$args{patron} and !ref($$args{patron}) ) {
353 $$args{patron_id} = $$args{patron};
354 delete $$args{patron};
358 if( $$args{noncat} ) {
359 $$args{is_noncat} = $$args{noncat};
360 delete $$args{noncat};
363 if( $$args{precat} ) {
364 $$args{is_precat} = $$args{request_precat} = $$args{precat};
365 delete $$args{precat};
371 # --------------------------------------------------------------------------
372 # This package actually manages all of the circulation logic
373 # --------------------------------------------------------------------------
374 package OpenILS::Application::Circ::Circulator;
375 use strict; use warnings;
376 use vars q/$AUTOLOAD/;
378 use OpenILS::Utils::Fieldmapper;
379 use OpenSRF::Utils::Cache;
380 use Digest::MD5 qw(md5_hex);
381 use DateTime::Format::ISO8601;
382 use OpenILS::Utils::PermitHold;
383 use OpenILS::Utils::DateTime qw/:datetime/;
384 use OpenSRF::Utils::SettingsClient;
385 use OpenILS::Application::Circ::Holds;
386 use OpenILS::Application::Circ::Transit;
387 use OpenSRF::Utils::Logger qw(:logger);
388 use OpenILS::Utils::CStoreEditor qw/:funcs/;
389 use OpenILS::Const qw/:const/;
390 use OpenILS::Utils::Penalty;
391 use OpenILS::Application::Circ::CircCommon;
394 my $CC = "OpenILS::Application::Circ::CircCommon";
395 my $holdcode = "OpenILS::Application::Circ::Holds";
396 my $transcode = "OpenILS::Application::Circ::Transit";
402 # --------------------------------------------------------------------------
403 # Add a pile of automagic getter/setter methods
404 # --------------------------------------------------------------------------
405 my @AUTOLOAD_FIELDS = qw/
417 overrides_per_copy_alerts
458 recurring_fines_level
463 auto_renewal_remaining
472 cancelled_hold_transit
480 circ_matrix_matchpoint
491 claims_never_checked_out
504 dont_change_lost_zero
506 needs_lost_bill_handling
512 my $type = ref($self) or die "$self is not an object";
514 my $name = $AUTOLOAD;
517 unless (grep { $_ eq $name } @AUTOLOAD_FIELDS) {
518 $logger->error("circulator: $type: invalid autoload field: $name");
519 die "$type: invalid autoload field: $name\n"
524 *{"${type}::${name}"} = sub {
527 $s->{$name} = $v if defined $v;
531 return $self->$name($data);
536 my( $class, $auth, %args ) = @_;
537 $class = ref($class) || $class;
538 my $self = bless( {}, $class );
541 $self->editor(new_editor(xact => 1, authtoken => $auth));
543 unless( $self->editor->checkauth ) {
544 $self->bail_on_events($self->editor->event);
548 $self->cache_handle(OpenSRF::Utils::Cache->new('global'));
550 $self->$_($args{$_}) for keys %args;
553 ($self->circ_lib) ? $self->circ_lib : $self->editor->requestor->ws_ou);
555 # if this is a renewal, default to desk_renewal
556 $self->desk_renewal(1) unless
557 $self->opac_renewal or $self->phone_renewal or $self->sip_renewal
558 or $self->auto_renewal;
560 $self->capture('') unless $self->capture;
562 unless(%user_groups) {
563 my $gps = $self->editor->retrieve_all_permission_grp_tree;
564 %user_groups = map { $_->id => $_ } @$gps;
571 # --------------------------------------------------------------------------
572 # True if we should discontinue processing
573 # --------------------------------------------------------------------------
575 my( $self, $bool ) = @_;
576 if( defined $bool ) {
577 $logger->info("circulator: BAILING OUT") if $bool;
578 $self->{bail_out} = $bool;
580 return $self->{bail_out};
585 my( $self, @evts ) = @_;
588 $e->{payload} = $self->copy if
589 ($e->{textcode} eq 'COPY_NOT_AVAILABLE');
591 $logger->info("circulator: pushing event ".$e->{textcode});
592 push( @{$self->events}, $e ) unless
593 grep { $_->{textcode} eq $e->{textcode} } @{$self->events};
599 return '' if $self->skip_permit_key;
600 my $key = md5_hex( time() . rand() . "$$" );
601 $self->cache_handle->put_cache( "oils_permit_key_$key", 1, 300 );
602 return $self->permit_key($key);
605 sub check_permit_key {
607 return 1 if $self->skip_permit_key;
608 my $key = $self->permit_key;
609 return 0 unless $key;
610 my $k = "oils_permit_key_$key";
611 my $one = $self->cache_handle->get_cache($k);
612 $self->cache_handle->delete_cache($k);
613 return ($one) ? 1 : 0;
616 sub seems_like_reservation {
619 # Some words about the following method:
620 # 1) It requires the VIEW_USER permission, but that's not an
621 # issue, right, since all staff should have that?
622 # 2) It returns only one reservation at a time, even if an item can be
623 # and is currently overbooked. Hmmm....
624 my $booking_ses = create OpenSRF::AppSession("open-ils.booking");
625 my $result = $booking_ses->request(
626 "open-ils.booking.reservations.by_returnable_resource_barcode",
627 $self->editor->authtoken,
630 $booking_ses->disconnect;
632 return $self->bail_on_events($result) if defined $U->event_code($result);
635 $self->reservation(shift @$result);
643 # save_trimmed_copy() used just to be a block in mk_env(), but was separated for re-use
644 sub save_trimmed_copy {
645 my ($self, $copy) = @_;
648 $self->volume($copy->call_number);
649 $self->title($self->volume->record);
650 $self->copy->call_number($self->volume->id);
651 $self->volume->record($self->title->id);
652 $self->is_precat(1) if $self->volume->id == OILS_PRECAT_CALL_NUMBER;
653 if($self->copy->deposit_amount and $self->copy->deposit_amount > 0) {
654 $self->is_deposit(1) if $U->is_true($self->copy->deposit);
655 $self->is_rental(1) unless $U->is_true($self->copy->deposit);
659 sub collect_user_copy_alerts {
661 my $e = $self->editor;
664 my $alerts = $e->search_asset_copy_alert([
665 {copy => $self->copy->id, ack_time => undef},
666 {flesh => 1, flesh_fields => { aca => [ qw/ alert_type / ] }}
668 if (ref $alerts eq "ARRAY") {
669 $logger->info("circulator: found " . scalar(@$alerts) . " alerts for copy " .
671 $self->user_copy_alerts($alerts);
676 sub filter_user_copy_alerts {
679 my $e = $self->editor;
681 if(my $alerts = $self->user_copy_alerts) {
683 my $suppress_orgs = $U->get_org_full_path($self->circ_lib);
684 my $suppressions = $e->search_actor_copy_alert_suppress(
685 {org => $suppress_orgs}
689 foreach my $a (@$alerts) {
690 # filter on event type
691 if (defined $a->alert_type) {
692 next if ($a->alert_type->event eq 'CHECKIN' && !$self->is_checkin && !$self->is_renewal);
693 next if ($a->alert_type->event eq 'CHECKOUT' && !$self->is_checkout && !$self->is_renewal);
694 next if (defined $a->alert_type->in_renew && $U->is_true($a->alert_type->in_renew) && !$self->is_renewal);
695 next if (defined $a->alert_type->in_renew && !$U->is_true($a->alert_type->in_renew) && $self->is_renewal);
698 # filter on suppression
699 next if (grep { $a->alert_type->id == $_->alert_type} @$suppressions);
701 # filter on "only at circ lib"
702 if (defined $a->alert_type->at_circ) {
703 my $copy_circ_lib = (ref $self->copy->circ_lib) ?
704 $self->copy->circ_lib->id : $self->copy->circ_lib;
705 my $orgs = $U->get_org_descendants($copy_circ_lib);
707 if ($U->is_true($a->alert_type->invert_location)) {
708 next if (grep {$_ == $self->circ_lib} @$orgs);
710 next unless (grep {$_ == $self->circ_lib} @$orgs);
714 # filter on "only at owning lib"
715 if (defined $a->alert_type->at_owning) {
716 my $copy_owning_lib = (ref $self->volume->owning_lib) ?
717 $self->volume->owning_lib->id : $self->volume->owning_lib;
718 my $orgs = $U->get_org_descendants($copy_owning_lib);
720 if ($U->is_true($a->alert_type->invert_location)) {
721 next if (grep {$_ == $self->circ_lib} @$orgs);
723 next unless (grep {$_ == $self->circ_lib} @$orgs);
727 $a->alert_type->next_status([$U->unique_unnested_numbers($a->alert_type->next_status)]);
729 push @final_alerts, $a;
732 $self->user_copy_alerts(\@final_alerts);
736 sub generate_system_copy_alerts {
738 return unless($self->copy);
740 # don't create system copy alerts if the copy
741 # is in a normal state; we're assuming that there's
742 # never a need to generate a popup for each and every
743 # checkin or checkout of normal items. If this assumption
744 # proves false, then we'll need to add a way to explicitly specify
745 # that a copy alert type should never generate a system copy alert
746 return if $self->copy_state eq 'NORMAL';
748 my $e = $self->editor;
750 my $suppress_orgs = $U->get_org_full_path($self->circ_lib);
751 my $suppressions = $e->search_actor_copy_alert_suppress(
752 {org => $suppress_orgs}
755 # events we care about ...
757 push(@$event, 'CHECKIN') if $self->is_checkin;
758 push(@$event, 'CHECKOUT') if $self->is_checkout;
759 return unless scalar(@$event);
761 my $alert_orgs = $U->get_org_ancestors($self->circ_lib);
762 my $alert_types = $e->search_config_copy_alert_type({
764 scope_org => $alert_orgs,
766 state => $self->copy_state,
767 '-or' => [ { in_renew => $self->is_renewal }, { in_renew => undef } ],
771 foreach my $a (@$alert_types) {
772 # filter on "only at circ lib"
773 if (defined $a->at_circ) {
774 my $copy_circ_lib = (ref $self->copy->circ_lib) ?
775 $self->copy->circ_lib->id : $self->copy->circ_lib;
776 my $orgs = $U->get_org_descendants($copy_circ_lib);
778 if ($U->is_true($a->invert_location)) {
779 next if (grep {$_ == $self->circ_lib} @$orgs);
781 next unless (grep {$_ == $self->circ_lib} @$orgs);
785 # filter on "only at owning lib"
786 if (defined $a->at_owning) {
787 my $copy_owning_lib = (ref $self->volume->owning_lib) ?
788 $self->volume->owning_lib->id : $self->volume->owning_lib;
789 my $orgs = $U->get_org_descendants($copy_owning_lib);
791 if ($U->is_true($a->invert_location)) {
792 next if (grep {$_ == $self->circ_lib} @$orgs);
794 next unless (grep {$_ == $self->circ_lib} @$orgs);
798 push @final_types, $a;
802 $logger->info("circulator: found " . scalar(@final_types) . " system alert types for copy" .
808 # keep track of conditions corresponding to suppressed
809 # system alerts, as these may be used to overridee
810 # certain old-style-events
811 my %auto_override_conditions = ();
812 foreach my $t (@final_types) {
813 if ($t->next_status) {
814 if (grep { $t->id == $_->alert_type } @$suppressions) {
817 $t->next_status([$U->unique_unnested_numbers($t->next_status)]);
821 my $alert = new Fieldmapper::asset::copy_alert ();
822 $alert->alert_type($t->id);
823 $alert->copy($self->copy->id);
825 $alert->create_staff($e->requestor->id);
826 $alert->create_time('now');
827 $alert->ack_staff($e->requestor->id);
828 $alert->ack_time('now');
830 $alert = $e->create_asset_copy_alert($alert);
834 $alert->alert_type($t->clone);
836 push(@{$self->next_copy_status}, @{$t->next_status}) if ($t->next_status);
837 if (grep {$_->alert_type == $t->id} @$suppressions) {
838 $auto_override_conditions{join("\t", $t->state, $t->event)} = 1;
840 push(@alerts, $alert) unless (grep {$_->alert_type == $t->id} @$suppressions);
843 $self->system_copy_alerts(\@alerts);
844 $self->overrides_per_copy_alerts(\%auto_override_conditions);
847 sub add_overrides_from_system_copy_alerts {
849 my $e = $self->editor;
851 foreach my $condition (keys %{$self->overrides_per_copy_alerts()}) {
852 if (exists $COPY_ALERT_OVERRIDES{$condition}) {
854 push @{$self->override_args->{events}}, @{ $COPY_ALERT_OVERRIDES{$condition} };
855 # special handling for long-overdue and lost checkouts
856 if (grep { $_ eq 'OPEN_CIRCULATION_EXISTS' } @{ $COPY_ALERT_OVERRIDES{$condition} }) {
857 my $state = (split /\t/, $condition, -1)[0];
859 if ($state eq 'LOST' or $state eq 'LOST_AND_PAID') {
860 $setting = 'circ.copy_alerts.forgive_fines_on_lost_checkin';
861 } elsif ($state eq 'LONGOVERDUE') {
862 $setting = 'circ.copy_alerts.forgive_fines_on_long_overdue_checkin';
866 my $forgive = $U->ou_ancestor_setting_value(
867 $self->circ_lib, $setting, $e
869 if ($U->is_true($forgive)) {
870 $self->void_overdues(1);
872 $self->noop(1); # do not attempt transits, just check it in
881 my $e = $self->editor;
883 $self->next_copy_status([]) unless (defined $self->next_copy_status);
884 $self->overrides_per_copy_alerts({}) unless (defined $self->overrides_per_copy_alerts);
886 # --------------------------------------------------------------------------
887 # Grab the fleshed copy
888 # --------------------------------------------------------------------------
889 unless($self->is_noncat) {
892 $copy = $e->retrieve_asset_copy(
893 [$self->copy_id, $MK_ENV_FLESH ]) or return $e->event;
895 } elsif( $self->copy_barcode ) {
897 $copy = $e->search_asset_copy(
898 [{barcode => $self->copy_barcode, deleted => 'f'}, $MK_ENV_FLESH ])->[0];
899 } elsif( $self->reservation ) {
900 my $res = $e->json_query(
902 "select" => {"acp" => ["id"]},
907 "field" => "barcode",
911 "field" => "current_resource"
920 "id" => (ref $self->reservation) ?
921 $self->reservation->id : $self->reservation
926 if (ref $res eq "ARRAY" and scalar @$res) {
927 $logger->info("circulator: mapped reservation " .
928 $self->reservation . " to copy " . $res->[0]->{"id"});
929 $copy = $e->retrieve_asset_copy([$res->[0]->{"id"}, $MK_ENV_FLESH]);
934 $self->save_trimmed_copy($copy);
939 {from => ['asset.copy_state', $copy->id]}
940 )->[0]{'asset.copy_state'}
943 $self->generate_system_copy_alerts;
944 $self->add_overrides_from_system_copy_alerts;
945 $self->collect_user_copy_alerts;
946 $self->filter_user_copy_alerts;
949 # We can't renew if there is no copy
950 return $self->bail_on_events(OpenILS::Event->new('ASSET_COPY_NOT_FOUND'))
951 if $self->is_renewal;
956 # --------------------------------------------------------------------------
958 # --------------------------------------------------------------------------
962 flesh_fields => {au => [ qw/ card / ]}
965 if( $self->patron_id ) {
966 $patron = $e->retrieve_actor_user([$self->patron_id, $flesh])
967 or return $self->bail_on_events(OpenILS::Event->new('ACTOR_USER_NOT_FOUND'));
969 } elsif( $self->patron_barcode ) {
971 # note: throwing ACTOR_USER_NOT_FOUND instead of ACTOR_CARD_NOT_FOUND is intentional
972 my $card = $e->search_actor_card({barcode => $self->patron_barcode})->[0]
973 or return $self->bail_on_events(OpenILS::Event->new('ACTOR_USER_NOT_FOUND'));
975 $patron = $e->retrieve_actor_user($card->usr)
976 or return $self->bail_on_events(OpenILS::Event->new('ACTOR_USER_NOT_FOUND'));
978 # Use the card we looked up, not the patron's primary, for card active checks
979 $patron->card($card);
982 if( my $copy = $self->copy ) {
985 $flesh->{flesh_fields}->{circ} = ['usr'];
987 my $circ = $e->search_action_circulation([
988 {target_copy => $copy->id, checkin_time => undef}, $flesh
992 $patron = $circ->usr;
993 $circ->usr($patron->id); # de-flesh for consistency
999 return $self->bail_on_events(OpenILS::Event->new('ACTOR_USER_NOT_FOUND'))
1000 unless $self->patron($patron) or $self->is_checkin;
1002 unless($self->is_checkin) {
1004 # Check for inactivity and patron reg. expiration
1006 $self->bail_on_events(OpenILS::Event->new('PATRON_INACTIVE'))
1007 unless $U->is_true($patron->active);
1009 $self->bail_on_events(OpenILS::Event->new('PATRON_CARD_INACTIVE'))
1010 unless $U->is_true($patron->card->active);
1012 # Expired patrons cannot check out. Renewals for expired
1013 # patrons depend on a setting and will be checked in the
1014 # do_renew subroutine.
1015 if ($self->is_checkout) {
1016 my $expire = DateTime::Format::ISO8601->new->parse_datetime(
1017 clean_ISO8601($patron->expire_date));
1019 if (CORE::time > $expire->epoch) {
1020 $self->bail_on_events(OpenILS::Event->new('PATRON_ACCOUNT_EXPIRED'))
1027 # --------------------------------------------------------------------------
1028 # Does the circ permit work
1029 # --------------------------------------------------------------------------
1033 $self->log_me("do_permit()");
1035 unless( $self->editor->requestor->id == $self->patron->id ) {
1036 return $self->bail_on_events($self->editor->event)
1037 unless( $self->editor->allowed('VIEW_PERMIT_CHECKOUT') );
1040 $self->check_captured_holds();
1041 $self->do_copy_checks();
1042 return if $self->bail_out;
1043 $self->run_patron_permit_scripts();
1044 $self->run_copy_permit_scripts()
1045 unless $self->is_precat or $self->is_noncat;
1046 $self->check_item_deposit_events();
1047 $self->override_events();
1048 return if $self->bail_out;
1050 if($self->is_precat and not $self->request_precat) {
1052 OpenILS::Event->new(
1053 'ITEM_NOT_CATALOGED', payload => $self->mk_permit_key));
1054 return $self->bail_out(1) unless $self->is_renewal;
1058 OpenILS::Event->new('SUCCESS', payload => $self->mk_permit_key));
1061 sub check_item_deposit_events {
1063 $self->push_events(OpenILS::Event->new('ITEM_DEPOSIT_REQUIRED', payload => $self->copy))
1064 if $self->is_deposit and not $self->is_deposit_exempt;
1065 $self->push_events(OpenILS::Event->new('ITEM_RENTAL_FEE_REQUIRED', payload => $self->copy))
1066 if $self->is_rental and not $self->is_rental_exempt;
1069 # returns true if the user is not required to pay deposits
1070 sub is_deposit_exempt {
1072 my $pid = (ref $self->patron->profile) ?
1073 $self->patron->profile->id : $self->patron->profile;
1074 my $groups = $U->ou_ancestor_setting_value(
1075 $self->circ_lib, 'circ.deposit.exempt_groups', $self->editor);
1076 for my $grp (@$groups) {
1077 return 1 if $self->is_group_descendant($grp, $pid);
1082 # returns true if the user is not required to pay rental fees
1083 sub is_rental_exempt {
1085 my $pid = (ref $self->patron->profile) ?
1086 $self->patron->profile->id : $self->patron->profile;
1087 my $groups = $U->ou_ancestor_setting_value(
1088 $self->circ_lib, 'circ.rental.exempt_groups', $self->editor);
1089 for my $grp (@$groups) {
1090 return 1 if $self->is_group_descendant($grp, $pid);
1095 sub is_group_descendant {
1096 my($self, $p_id, $c_id) = @_;
1097 return 0 unless defined $p_id and defined $c_id;
1098 return 1 if $c_id == $p_id;
1099 while(my $grp = $user_groups{$c_id}) {
1100 $c_id = $grp->parent;
1101 return 0 unless defined $c_id;
1102 return 1 if $c_id == $p_id;
1107 sub check_captured_holds {
1109 my $copy = $self->copy;
1110 my $patron = $self->patron;
1112 return undef unless $copy;
1114 my $s = $U->copy_status($copy->status)->id;
1115 return unless $s == OILS_COPY_STATUS_ON_HOLDS_SHELF;
1116 $logger->info("circulator: copy is on holds shelf, searching for the correct hold");
1118 # Item is on the holds shelf, make sure it's going to the right person
1119 my $hold = $self->editor->search_action_hold_request(
1122 current_copy => $copy->id ,
1123 capture_time => { '!=' => undef },
1124 cancel_time => undef,
1125 fulfillment_time => undef
1129 flesh_fields => { ahr => ['usr'] }
1134 if ($hold and $hold->usr->id == $patron->id) {
1135 $self->checkout_is_for_hold(1);
1139 my $holdau = $hold->usr;
1142 $payload->{patron_name} = $holdau->first_given_name . ' ' . $holdau->family_name;
1143 $payload->{patron_id} = $holdau->id;
1145 $payload->{patron_name} = "???";
1147 $payload->{hold_id} = $hold->id;
1148 $self->push_events(OpenILS::Event->new('ITEM_ON_HOLDS_SHELF',
1149 payload => $payload));
1152 $logger->info("circulator: this copy is needed by a different patron to fulfill a hold");
1157 sub do_copy_checks {
1159 my $copy = $self->copy;
1160 return unless $copy;
1162 my $stat = $U->copy_status($copy->status)->id;
1164 # We cannot check out a copy if it is in-transit
1165 if( $stat == OILS_COPY_STATUS_IN_TRANSIT ) {
1166 return $self->bail_on_events(OpenILS::Event->new('COPY_IN_TRANSIT'));
1169 $self->handle_claims_returned();
1170 return if $self->bail_out;
1172 # no claims returned circ was found, check if there is any open circ
1173 unless( $self->is_renewal ) {
1175 my $circs = $self->editor->search_action_circulation(
1176 { target_copy => $copy->id, checkin_time => undef }
1179 if(my $old_circ = $circs->[0]) { # an open circ was found
1181 my $payload = {copy => $copy};
1183 if($old_circ->usr == $self->patron->id) {
1185 $payload->{old_circ} = $old_circ;
1187 # If there is an open circulation on the checkout item and an auto-renew
1188 # interval is defined, inform the caller that they should go
1189 # ahead and renew the item instead of warning about open circulations.
1191 my $auto_renew_intvl = $U->ou_ancestor_setting_value(
1193 'circ.checkout_auto_renew_age',
1197 if($auto_renew_intvl) {
1198 my $intvl_seconds = OpenILS::Utils::DateTime->interval_to_seconds($auto_renew_intvl);
1199 my $checkout_time = DateTime::Format::ISO8601->new->parse_datetime( clean_ISO8601($old_circ->xact_start) );
1201 if(DateTime->now > $checkout_time->add(seconds => $intvl_seconds)) {
1202 $payload->{auto_renew} = 1;
1207 return $self->bail_on_events(
1208 OpenILS::Event->new('OPEN_CIRCULATION_EXISTS', payload => $payload)
1214 my $LEGACY_CIRC_EVENT_MAP = {
1215 'no_item' => 'ITEM_NOT_CATALOGED',
1216 'actor.usr.barred' => 'PATRON_BARRED',
1217 'asset.copy.circulate' => 'COPY_CIRC_NOT_ALLOWED',
1218 'asset.copy.status' => 'COPY_NOT_AVAILABLE',
1219 'asset.copy_location.circulate' => 'COPY_CIRC_NOT_ALLOWED',
1220 'config.circ_matrix_test.circulate' => 'COPY_CIRC_NOT_ALLOWED',
1221 'config.circ_matrix_test.max_items_out' => 'PATRON_EXCEEDS_CHECKOUT_COUNT',
1222 'config.circ_matrix_test.max_overdue' => 'PATRON_EXCEEDS_OVERDUE_COUNT',
1223 'config.circ_matrix_test.max_fines' => 'PATRON_EXCEEDS_FINES',
1224 'config.circ_matrix_circ_mod_test' => 'PATRON_EXCEEDS_CHECKOUT_COUNT',
1225 'config.circ_matrix_test.total_copy_hold_ratio' =>
1226 'TOTAL_HOLD_COPY_RATIO_EXCEEDED',
1227 'config.circ_matrix_test.available_copy_hold_ratio' =>
1228 'AVAIL_HOLD_COPY_RATIO_EXCEEDED'
1232 # ---------------------------------------------------------------------
1233 # This pushes any patron-related events into the list but does not
1234 # set bail_out for any events
1235 # ---------------------------------------------------------------------
1236 sub run_patron_permit_scripts {
1238 my $patronid = $self->patron->id;
1243 my $results = $self->run_indb_circ_test;
1244 unless($self->circ_test_success) {
1245 my @trimmed_results;
1247 if ($self->is_noncat) {
1248 # no_item result is OK during noncat checkout
1249 @trimmed_results = grep { ($_->{fail_part} || '') ne 'no_item' } @$results;
1253 if ($self->checkout_is_for_hold) {
1254 # if this checkout will fulfill a hold, ignore CIRC blocks
1255 # and rely instead on the (later-checked) FULFILL block
1257 my @pen_names = grep {$_} map {$_->{fail_part}} @$results;
1258 my $fblock_pens = $self->editor->search_config_standing_penalty(
1259 {name => [@pen_names], block_list => {like => '%CIRC%'}});
1261 for my $res (@$results) {
1262 my $name = $res->{fail_part} || '';
1263 next if grep {$_->name eq $name} @$fblock_pens;
1264 push(@trimmed_results, $res);
1268 # not for hold or noncat
1269 @trimmed_results = @$results;
1273 # update the final set of test results
1274 $self->matrix_test_result(\@trimmed_results);
1276 push @allevents, $self->matrix_test_result_events;
1280 $_->{payload} = $self->copy if
1281 ($_->{textcode} eq 'COPY_NOT_AVAILABLE');
1284 $logger->info("circulator: permit_patron script returned events: @allevents") if @allevents;
1286 $self->push_events(@allevents);
1289 sub matrix_test_result_codes {
1291 map { $_->{"fail_part"} } @{$self->matrix_test_result};
1294 sub matrix_test_result_events {
1297 my $event = new OpenILS::Event(
1298 $LEGACY_CIRC_EVENT_MAP->{$_->{"fail_part"}} || $_->{"fail_part"}
1300 $event->{"payload"} = {"fail_part" => $_->{"fail_part"}};
1302 } (@{$self->matrix_test_result});
1305 sub run_indb_circ_test {
1307 return $self->matrix_test_result if $self->matrix_test_result;
1309 # Before we run the database function, let's make sure that the patron's
1310 # threshold-based penalties are up-to-date, so that the database function
1311 # can take them into consideration.
1313 # This takes place in a separate cstore editor and db transaction, so that
1314 # even if the circulation fails and its transaction is rolled back, any
1315 # newly calculated penalties remain on the patron's account.
1317 # Note that this depends on the PostgreSQL transaction isolation level
1318 # being "read committed" (which it is by default); if it were "repeatable
1319 # read" or "serializable", the in-DB circ/renew test that follows would not
1320 # see the updated penalties.
1321 my $penalty_editor = new_editor(xact => 1, authtoken => $self->editor->authtoken);
1322 return $penalty_editor->event unless( $penalty_editor->checkauth );
1323 OpenILS::Utils::Penalty->calculate_penalties($penalty_editor, $self->patron->id, $self->circ_lib);
1324 $penalty_editor->commit;
1326 my $dbfunc = ($self->is_renewal) ?
1327 'action.item_user_renew_test' : 'action.item_user_circ_test';
1329 if( $self->is_precat && $self->request_precat) {
1330 $self->make_precat_copy;
1331 return if $self->bail_out;
1334 my $results = $self->editor->json_query(
1338 ($self->is_noncat or ($self->is_precat and !$self->override and !$self->is_renewal)) ? undef : $self->copy->id,
1344 $self->circ_test_success($U->is_true($results->[0]->{success}));
1346 if(my $mp = $results->[0]->{matchpoint}) {
1347 $logger->info("circulator: circ policy test found matchpoint built via rows " . $results->[0]->{buildrows});
1348 $self->circ_matrix_matchpoint($self->editor->retrieve_config_circ_matrix_matchpoint($mp));
1349 $self->circ_matrix_matchpoint->duration_rule($self->editor->retrieve_config_rules_circ_duration($results->[0]->{duration_rule}));
1350 if(defined($results->[0]->{renewals})) {
1351 $self->circ_matrix_matchpoint->duration_rule->max_renewals($results->[0]->{renewals});
1353 $self->circ_matrix_matchpoint->recurring_fine_rule($self->editor->retrieve_config_rules_recurring_fine($results->[0]->{recurring_fine_rule}));
1354 if(defined($results->[0]->{grace_period})) {
1355 $self->circ_matrix_matchpoint->recurring_fine_rule->grace_period($results->[0]->{grace_period});
1357 $self->circ_matrix_matchpoint->max_fine_rule($self->editor->retrieve_config_rules_max_fine($results->[0]->{max_fine_rule}));
1358 if(defined($results->[0]->{hard_due_date})) {
1359 $self->circ_matrix_matchpoint->hard_due_date($self->editor->retrieve_config_hard_due_date($results->[0]->{hard_due_date}));
1361 # Grab the *last* response for limit_groups, where it is more likely to be filled
1362 $self->limit_groups($results->[-1]->{limit_groups});
1365 return $self->matrix_test_result($results);
1368 # ---------------------------------------------------------------------
1369 # given a use and copy, this will calculate the circulation policy
1370 # parameters. Only works with in-db circ.
1371 # ---------------------------------------------------------------------
1375 return OpenILS::Event->new('ASSET_COPY_NOT_FOUND') unless $self->copy;
1377 $self->run_indb_circ_test;
1380 circ_test_success => $self->circ_test_success,
1381 failure_events => [],
1382 failure_codes => [],
1383 matchpoint => $self->circ_matrix_matchpoint
1386 unless($self->circ_test_success) {
1387 $results->{"failure_codes"} = [ $self->matrix_test_result_codes ];
1388 $results->{"failure_events"} = [ $self->matrix_test_result_events ];
1391 if($self->circ_matrix_matchpoint) {
1392 my $duration_rule = $self->circ_matrix_matchpoint->duration_rule;
1393 my $recurring_fine_rule = $self->circ_matrix_matchpoint->recurring_fine_rule;
1394 my $max_fine_rule = $self->circ_matrix_matchpoint->max_fine_rule;
1395 my $hard_due_date = $self->circ_matrix_matchpoint->hard_due_date;
1397 my $policy = $self->get_circ_policy(
1398 $duration_rule, $recurring_fine_rule, $max_fine_rule, $hard_due_date);
1400 $$results{$_} = $$policy{$_} for keys %$policy;
1406 # ---------------------------------------------------------------------
1407 # Loads the circ policy info for duration, recurring fine, and max
1408 # fine based on the current copy
1409 # ---------------------------------------------------------------------
1410 sub get_circ_policy {
1411 my($self, $duration_rule, $recurring_fine_rule, $max_fine_rule, $hard_due_date) = @_;
1414 duration_rule => $duration_rule->name,
1415 recurring_fine_rule => $recurring_fine_rule->name,
1416 max_fine_rule => $max_fine_rule->name,
1417 max_fine => $self->get_max_fine_amount($max_fine_rule),
1418 fine_interval => $recurring_fine_rule->recurrence_interval,
1419 renewal_remaining => $duration_rule->max_renewals,
1420 auto_renewal_remaining => $duration_rule->max_auto_renewals,
1421 grace_period => $recurring_fine_rule->grace_period
1424 if($hard_due_date) {
1425 $policy->{duration_date_ceiling} = $hard_due_date->ceiling_date;
1426 $policy->{duration_date_ceiling_force} = $hard_due_date->forceto;
1429 $policy->{duration_date_ceiling} = undef;
1430 $policy->{duration_date_ceiling_force} = undef;
1433 $policy->{duration} = $duration_rule->shrt
1434 if $self->copy->loan_duration == OILS_CIRC_DURATION_SHORT;
1435 $policy->{duration} = $duration_rule->normal
1436 if $self->copy->loan_duration == OILS_CIRC_DURATION_NORMAL;
1437 $policy->{duration} = $duration_rule->extended
1438 if $self->copy->loan_duration == OILS_CIRC_DURATION_EXTENDED;
1440 $policy->{recurring_fine} = $recurring_fine_rule->low
1441 if $self->copy->fine_level == OILS_REC_FINE_LEVEL_LOW;
1442 $policy->{recurring_fine} = $recurring_fine_rule->normal
1443 if $self->copy->fine_level == OILS_REC_FINE_LEVEL_NORMAL;
1444 $policy->{recurring_fine} = $recurring_fine_rule->high
1445 if $self->copy->fine_level == OILS_REC_FINE_LEVEL_HIGH;
1450 sub get_max_fine_amount {
1452 my $max_fine_rule = shift;
1453 my $max_amount = $max_fine_rule->amount;
1455 # if is_percent is true then the max->amount is
1456 # use as a percentage of the copy price
1457 if ($U->is_true($max_fine_rule->is_percent)) {
1458 my $price = $U->get_copy_price($self->editor, $self->copy, $self->volume);
1459 $max_amount = $price * $max_fine_rule->amount / 100;
1461 $U->ou_ancestor_setting_value(
1463 'circ.max_fine.cap_at_price',
1467 my $price = $U->get_copy_price($self->editor, $self->copy, $self->volume);
1468 $max_amount = ( $price && $max_amount > $price ) ? $price : $max_amount;
1476 sub run_copy_permit_scripts {
1478 my $copy = $self->copy || return;
1482 my $results = $self->run_indb_circ_test;
1483 push @allevents, $self->matrix_test_result_events
1484 unless $self->circ_test_success;
1486 # See if this copy has an alert message
1487 my $ae = $self->check_copy_alert();
1488 push( @allevents, $ae ) if $ae;
1490 # uniquify the events
1491 my %hash = map { ($_->{ilsevent} => $_) } @allevents;
1492 @allevents = values %hash;
1494 $logger->info("circulator: permit_copy script returned events: @allevents") if @allevents;
1496 $self->push_events(@allevents);
1500 sub check_copy_alert {
1503 if ($self->new_copy_alerts) {
1505 push @alerts, @{$self->user_copy_alerts} # we have preexisting alerts
1506 if ($self->user_copy_alerts && @{$self->user_copy_alerts});
1508 push @alerts, @{$self->system_copy_alerts} # we have new dynamic alerts
1509 if ($self->system_copy_alerts && @{$self->system_copy_alerts});
1512 $self->bail_out(1) if (!$self->override);
1513 return OpenILS::Event->new( 'COPY_ALERT_MESSAGE', payload => \@alerts);
1517 return undef if $self->is_renewal;
1518 return OpenILS::Event->new(
1519 'COPY_ALERT_MESSAGE', payload => $self->copy->alert_message)
1520 if $self->copy and $self->copy->alert_message;
1526 # --------------------------------------------------------------------------
1527 # If the call is overriding and has permissions to override every collected
1528 # event, the are cleared. Any event that the caller does not have
1529 # permission to override, will be left in the event list and bail_out will
1531 # XXX We need code in here to cancel any holds/transits on copies
1532 # that are being force-checked out
1533 # --------------------------------------------------------------------------
1534 sub override_events {
1536 my @events = @{$self->events};
1537 return unless @events;
1538 my $oargs = $self->override_args;
1540 if(!$self->override) {
1541 return $self->bail_out(1)
1542 if( @events > 1 or $events[0]->{textcode} ne 'SUCCESS' );
1547 for my $e (@events) {
1548 my $tc = $e->{textcode};
1549 next if $tc eq 'SUCCESS';
1550 if($oargs->{all} || grep { $_ eq $tc } @{$oargs->{events}}) {
1551 my $ov = "$tc.override";
1552 $logger->info("circulator: attempting to override event: $ov");
1554 return $self->bail_on_events($self->editor->event)
1555 unless( $self->editor->allowed($ov) );
1557 return $self->bail_out(1);
1563 # --------------------------------------------------------------------------
1564 # If there is an open claimsreturn circ on the requested copy, close the
1565 # circ if overriding, otherwise bail out
1566 # --------------------------------------------------------------------------
1567 sub handle_claims_returned {
1569 my $copy = $self->copy;
1571 my $CR = $self->editor->search_action_circulation(
1573 target_copy => $copy->id,
1574 stop_fines => OILS_STOP_FINES_CLAIMSRETURNED,
1575 checkin_time => undef,
1579 return unless ($CR = $CR->[0]);
1583 # - If the caller has set the override flag, we will check the item in
1584 if($self->override && ($self->override_args->{all} || grep { $_ eq 'CIRC_CLAIMS_RETURNED' } @{$self->override_args->{events}}) ) {
1586 $CR->checkin_time('now');
1587 $CR->checkin_scan_time('now');
1588 $CR->checkin_lib($self->circ_lib);
1589 $CR->checkin_workstation($self->editor->requestor->wsid);
1590 $CR->checkin_staff($self->editor->requestor->id);
1592 $evt = $self->editor->event
1593 unless $self->editor->update_action_circulation($CR);
1596 $evt = OpenILS::Event->new('CIRC_CLAIMS_RETURNED');
1599 $self->bail_on_events($evt) if $evt;
1604 # --------------------------------------------------------------------------
1605 # This performs the checkout
1606 # --------------------------------------------------------------------------
1610 $self->log_me("do_checkout()");
1612 # make sure perms are good if this isn't a renewal
1613 unless( $self->is_renewal ) {
1614 return $self->bail_on_events($self->editor->event)
1615 unless( $self->editor->allowed('COPY_CHECKOUT') );
1618 # verify the permit key
1619 unless( $self->check_permit_key ) {
1620 if( $self->permit_override ) {
1621 return $self->bail_on_events($self->editor->event)
1622 unless $self->editor->allowed('CIRC_PERMIT_OVERRIDE');
1624 return $self->bail_on_events(OpenILS::Event->new('CIRC_PERMIT_BAD_KEY'))
1628 # if this is a non-cataloged circ, build the circ and finish
1629 if( $self->is_noncat ) {
1630 $self->checkout_noncat;
1632 OpenILS::Event->new('SUCCESS',
1633 payload => { noncat_circ => $self->circ }));
1637 if( $self->is_precat ) {
1638 $self->make_precat_copy;
1639 return if $self->bail_out;
1641 } elsif( $self->copy->call_number == OILS_PRECAT_CALL_NUMBER ) {
1642 return $self->bail_on_events(OpenILS::Event->new('ITEM_NOT_CATALOGED'));
1645 $self->do_copy_checks;
1646 return if $self->bail_out;
1648 $self->run_checkout_scripts();
1649 return if $self->bail_out;
1651 $self->build_checkout_circ_object();
1652 return if $self->bail_out;
1654 my $modify_to_start = $self->booking_adjusted_due_date();
1655 return if $self->bail_out;
1657 $self->apply_modified_due_date($modify_to_start);
1658 return if $self->bail_out;
1660 return $self->bail_on_events($self->editor->event)
1661 unless $self->editor->create_action_circulation($self->circ);
1663 # refresh the circ to force local time zone for now
1664 $self->circ($self->editor->retrieve_action_circulation($self->circ->id));
1666 if($self->limit_groups) {
1667 $self->editor->json_query({ from => ['action.link_circ_limit_groups', $self->circ->id, $self->limit_groups] });
1670 $self->copy->status(OILS_COPY_STATUS_CHECKED_OUT);
1672 return if $self->bail_out;
1674 $self->apply_deposit_fee();
1675 return if $self->bail_out;
1677 $self->handle_checkout_holds();
1678 return if $self->bail_out;
1680 # ------------------------------------------------------------------------------
1681 # Update the patron penalty info in the DB, now that the item is checked out and
1682 # may cause the patron to reach certain thresholds.
1683 # ------------------------------------------------------------------------------
1684 OpenILS::Utils::Penalty->calculate_penalties($self->editor, $self->patron->id, $self->circ_lib);
1686 my $record = $U->record_to_mvr($self->title) unless $self->is_precat;
1689 if($self->is_renewal) {
1690 # flesh the billing summary for the checked-in circ
1691 $pcirc = $self->editor->retrieve_action_circulation([
1693 {flesh => 2, flesh_fields => {circ => ['billable_transaction'], mbt => ['summary']}}
1698 OpenILS::Event->new('SUCCESS',
1700 copy => $U->unflesh_copy($self->copy),
1701 volume => $self->volume,
1702 circ => $self->circ,
1704 holds_fulfilled => $self->fulfilled_holds,
1705 deposit_billing => $self->deposit_billing,
1706 rental_billing => $self->rental_billing,
1707 parent_circ => $pcirc,
1708 patron => ($self->return_patron) ? $self->patron : undef,
1709 patron_money => $self->editor->retrieve_money_user_summary($self->patron->id)
1715 sub apply_deposit_fee {
1717 my $copy = $self->copy;
1719 ($self->is_deposit and not $self->is_deposit_exempt) or
1720 ($self->is_rental and not $self->is_rental_exempt);
1722 return if $self->is_deposit and $self->skip_deposit_fee;
1723 return if $self->is_rental and $self->skip_rental_fee;
1725 my $bill = Fieldmapper::money::billing->new;
1726 my $amount = $copy->deposit_amount;
1730 if($self->is_deposit) {
1731 $billing_type = OILS_BILLING_TYPE_DEPOSIT;
1733 $self->deposit_billing($bill);
1735 $billing_type = OILS_BILLING_TYPE_RENTAL;
1737 $self->rental_billing($bill);
1740 $bill->xact($self->circ->id);
1741 $bill->amount($amount);
1742 $bill->note(OILS_BILLING_NOTE_SYSTEM);
1743 $bill->billing_type($billing_type);
1744 $bill->btype($btype);
1745 $self->editor->create_money_billing($bill) or $self->bail_on_events($self->editor->event);
1747 $logger->info("circulator: charged $amount on checkout with billing type $billing_type");
1752 my $copy = $self->copy;
1754 my $stat = $copy->status if ref $copy->status;
1755 my $loc = $copy->location if ref $copy->location;
1756 my $circ_lib = $copy->circ_lib if ref $copy->circ_lib;
1758 $copy->status($stat->id) if $stat;
1759 $copy->location($loc->id) if $loc;
1760 $copy->circ_lib($circ_lib->id) if $circ_lib;
1761 $copy->editor($self->editor->requestor->id);
1762 $copy->edit_date('now');
1763 $copy->age_protect($copy->age_protect->id) if ref $copy->age_protect;
1765 return $self->bail_on_events($self->editor->event)
1766 unless $self->editor->update_asset_copy($self->copy);
1768 $copy->status($U->copy_status($copy->status));
1769 $copy->location($loc) if $loc;
1770 $copy->circ_lib($circ_lib) if $circ_lib;
1773 sub update_reservation {
1775 my $reservation = $self->reservation;
1777 my $usr = $reservation->usr;
1778 my $target_rt = $reservation->target_resource_type;
1779 my $target_r = $reservation->target_resource;
1780 my $current_r = $reservation->current_resource;
1782 $reservation->usr($usr->id) if ref $usr;
1783 $reservation->target_resource_type($target_rt->id) if ref $target_rt;
1784 $reservation->target_resource($target_r->id) if ref $target_r;
1785 $reservation->current_resource($current_r->id) if ref $current_r;
1787 return $self->bail_on_events($self->editor->event)
1788 unless $self->editor->update_booking_reservation($self->reservation);
1791 ($reservation, $evt) = $U->fetch_booking_reservation($reservation->id);
1792 $self->reservation($reservation);
1796 sub bail_on_events {
1797 my( $self, @evts ) = @_;
1798 $self->push_events(@evts);
1802 # ------------------------------------------------------------------------------
1803 # A hold FULFILL block is just like a CIRC block, except that FULFILL only
1804 # affects copies that will fulfill holds and CIRC affects all other copies.
1805 # If blocks exists, bail, push Events onto the event pile, and return true.
1806 # ------------------------------------------------------------------------------
1807 sub check_hold_fulfill_blocks {
1810 # With the addition of ignore_proximity in csp, we need to fetch
1811 # the proximity of both the circ_lib and the copy's circ_lib to
1812 # the patron's home_ou.
1813 my ($ou_prox, $copy_prox);
1814 my $home_ou = (ref($self->patron->home_ou)) ? $self->patron->home_ou->id : $self->patron->home_ou;
1815 $ou_prox = $U->get_org_unit_proximity($self->editor, $home_ou, $self->circ_lib);
1816 $ou_prox = -1 unless (defined($ou_prox));
1817 my $copy_ou = (ref($self->copy->circ_lib)) ? $self->copy->circ_lib->id : $self->copy->circ_lib;
1818 if ($copy_ou == $self->circ_lib) {
1819 # Save us the time of an extra query.
1820 $copy_prox = $ou_prox;
1822 $copy_prox = $U->get_org_unit_proximity($self->editor, $home_ou, $copy_ou);
1823 $copy_prox = -1 unless (defined($copy_prox));
1826 # See if the user has any penalties applied that prevent hold fulfillment
1827 my $pens = $self->editor->json_query({
1828 select => {csp => ['name', 'label']},
1829 from => {ausp => {csp => {}}},
1832 usr => $self->patron->id,
1833 org_unit => $U->get_org_full_path($self->circ_lib),
1835 {stop_date => undef},
1836 {stop_date => {'>' => 'now'}}
1840 block_list => {'like' => '%FULFILL%'},
1842 {ignore_proximity => undef},
1843 {ignore_proximity => {'<' => $ou_prox}},
1844 {ignore_proximity => {'<' => $copy_prox}}
1850 return 0 unless @$pens;
1852 for my $pen (@$pens) {
1853 $logger->info("circulator: patron has hold FULFILL block " . $pen->{name});
1854 my $event = OpenILS::Event->new($pen->{name});
1855 $event->{desc} = $pen->{label};
1856 $self->push_events($event);
1859 $self->override_events;
1860 return $self->bail_out;
1864 # ------------------------------------------------------------------------------
1865 # When an item is checked out, see if we can fulfill a hold for this patron
1866 # ------------------------------------------------------------------------------
1867 sub handle_checkout_holds {
1869 my $copy = $self->copy;
1870 my $patron = $self->patron;
1872 my $e = $self->editor;
1873 $self->fulfilled_holds([]);
1875 # non-cats can't fulfill a hold
1876 return if $self->is_noncat;
1878 my $hold = $e->search_action_hold_request({
1879 current_copy => $copy->id ,
1880 cancel_time => undef,
1881 fulfillment_time => undef
1884 if($hold and $hold->usr != $patron->id) {
1885 # reset the hold since the copy is now checked out
1887 $logger->info("circulator: un-targeting hold ".$hold->id.
1888 " because copy ".$copy->id." is getting checked out");
1890 $hold->clear_prev_check_time;
1891 $hold->clear_current_copy;
1892 $hold->clear_capture_time;
1893 $hold->clear_shelf_time;
1894 $hold->clear_shelf_expire_time;
1895 $hold->clear_current_shelf_lib;
1897 return $self->bail_on_event($e->event)
1898 unless $e->update_action_hold_request($hold);
1904 $hold = $self->find_related_user_hold($copy, $patron) or return;
1905 $logger->info("circulator: found related hold to fulfill in checkout");
1908 return if $self->check_hold_fulfill_blocks;
1910 $logger->debug("circulator: checkout fulfilling hold " . $hold->id);
1912 # if the hold was never officially captured, capture it.
1913 $hold->clear_hopeless_date;
1914 $hold->current_copy($copy->id);
1915 $hold->capture_time('now') unless $hold->capture_time;
1916 $hold->fulfillment_time('now');
1917 $hold->fulfillment_staff($e->requestor->id);
1918 $hold->fulfillment_lib($self->circ_lib);
1920 return $self->bail_on_events($e->event)
1921 unless $e->update_action_hold_request($hold);
1923 return $self->fulfilled_holds([$hold->id]);
1927 # ------------------------------------------------------------------------------
1928 # If the circ.checkout_fill_related_hold setting is turned on and no hold for
1929 # the patron directly targets the checked out item, see if there is another hold
1930 # for the patron that could be fulfilled by the checked out item. Fulfill the
1931 # oldest hold and only fulfill 1 of them.
1933 # For "another hold":
1935 # First, check for one that the copy matches via hold_copy_map, ensuring that
1936 # *any* hold type that this copy could fill may end up filled.
1938 # Then, if circ.checkout_fill_related_hold_exact_match_only is not enabled, look
1939 # for a Title (T) or Volume (V) hold that matches the item. This allows items
1940 # that are non-requestable to count as capturing those hold types.
1941 # ------------------------------------------------------------------------------
1942 sub find_related_user_hold {
1943 my($self, $copy, $patron) = @_;
1944 my $e = $self->editor;
1946 # holds on precat copies are always copy-level, so this call will
1947 # always return undef. Exit early.
1948 return undef if $self->is_precat;
1950 return undef unless $U->ou_ancestor_setting_value(
1951 $self->circ_lib, 'circ.checkout_fills_related_hold', $e);
1953 # find the oldest unfulfilled hold that has not yet hit the holds shelf.
1955 select => {ahr => ['id']},
1964 fkey => 'current_copy',
1965 type => 'left' # there may be no current_copy
1972 fulfillment_time => undef,
1973 cancel_time => undef,
1975 {expire_time => undef},
1976 {expire_time => {'>' => 'now'}}
1980 target_copy => $self->copy->id
1984 {id => undef}, # left-join copy may be nonexistent
1985 {status => {'!=' => OILS_COPY_STATUS_ON_HOLDS_SHELF}},
1989 order_by => {ahr => {request_time => {direction => 'asc'}}},
1993 my $hold_info = $e->json_query($args)->[0];
1994 return $e->retrieve_action_hold_request($hold_info->{id}) if $hold_info;
1995 return undef if $U->ou_ancestor_setting_value(
1996 $self->circ_lib, 'circ.checkout_fills_related_hold_exact_match_only', $e);
1998 # find the oldest unfulfilled hold that has not yet hit the holds shelf.
2000 select => {ahr => ['id']},
2005 fkey => 'current_copy',
2006 type => 'left' # there may be no current_copy
2013 fulfillment_time => undef,
2014 cancel_time => undef,
2016 {expire_time => undef},
2017 {expire_time => {'>' => 'now'}}
2024 target => $self->volume->id
2030 target => $self->title->id
2036 {id => undef}, # left-join copy may be nonexistent
2037 {status => {'!=' => OILS_COPY_STATUS_ON_HOLDS_SHELF}},
2041 order_by => {ahr => {request_time => {direction => 'asc'}}},
2045 $hold_info = $e->json_query($args)->[0];
2046 return $e->retrieve_action_hold_request($hold_info->{id}) if $hold_info;
2051 sub run_checkout_scripts {
2064 my $hard_due_date_name;
2066 $self->run_indb_circ_test();
2067 $duration = $self->circ_matrix_matchpoint->duration_rule;
2068 $recurring = $self->circ_matrix_matchpoint->recurring_fine_rule;
2069 $max_fine = $self->circ_matrix_matchpoint->max_fine_rule;
2070 $hard_due_date = $self->circ_matrix_matchpoint->hard_due_date;
2072 $duration_name = $duration->name if $duration;
2073 if( $duration_name ne OILS_UNLIMITED_CIRC_DURATION ) {
2076 ($duration, $evt) = $U->fetch_circ_duration_by_name($duration_name);
2077 return $self->bail_on_events($evt) if ($evt && !$nobail);
2079 ($recurring, $evt) = $U->fetch_recurring_fine_by_name($recurring_name);
2080 return $self->bail_on_events($evt) if ($evt && !$nobail);
2082 ($max_fine, $evt) = $U->fetch_max_fine_by_name($max_fine_name);
2083 return $self->bail_on_events($evt) if ($evt && !$nobail);
2085 if($hard_due_date_name) {
2086 ($hard_due_date, $evt) = $U->fetch_hard_due_date_by_name($hard_due_date_name);
2087 return $self->bail_on_events($evt) if ($evt && !$nobail);
2093 # The item circulates with an unlimited duration
2097 $hard_due_date = undef;
2100 $self->duration_rule($duration);
2101 $self->recurring_fines_rule($recurring);
2102 $self->max_fine_rule($max_fine);
2103 $self->hard_due_date($hard_due_date);
2107 sub build_checkout_circ_object {
2110 my $circ = Fieldmapper::action::circulation->new;
2111 my $duration = $self->duration_rule;
2112 my $max = $self->max_fine_rule;
2113 my $recurring = $self->recurring_fines_rule;
2114 my $hard_due_date = $self->hard_due_date;
2115 my $copy = $self->copy;
2116 my $patron = $self->patron;
2117 my $duration_date_ceiling;
2118 my $duration_date_ceiling_force;
2122 my $policy = $self->get_circ_policy($duration, $recurring, $max, $hard_due_date);
2123 $duration_date_ceiling = $policy->{duration_date_ceiling};
2124 $duration_date_ceiling_force = $policy->{duration_date_ceiling_force};
2126 my $dname = $duration->name;
2127 my $mname = $max->name;
2128 my $rname = $recurring->name;
2130 if($hard_due_date) {
2131 $hdname = $hard_due_date->name;
2134 $logger->debug("circulator: building circulation ".
2135 "with duration=$dname, maxfine=$mname, recurring=$rname, hard due date=$hdname");
2137 $circ->duration($policy->{duration});
2138 $circ->recurring_fine($policy->{recurring_fine});
2139 $circ->duration_rule($duration->name);
2140 $circ->recurring_fine_rule($recurring->name);
2141 $circ->max_fine_rule($max->name);
2142 $circ->max_fine($policy->{max_fine});
2143 $circ->fine_interval($recurring->recurrence_interval);
2144 $circ->renewal_remaining($duration->max_renewals);
2145 $circ->auto_renewal_remaining($duration->max_auto_renewals);
2146 $circ->grace_period($policy->{grace_period});
2150 $logger->info("circulator: copy found with an unlimited circ duration");
2151 $circ->duration_rule(OILS_UNLIMITED_CIRC_DURATION);
2152 $circ->recurring_fine_rule(OILS_UNLIMITED_CIRC_DURATION);
2153 $circ->max_fine_rule(OILS_UNLIMITED_CIRC_DURATION);
2154 $circ->renewal_remaining(0);
2155 $circ->grace_period(0);
2158 $circ->target_copy( $copy->id );
2159 $circ->usr( $patron->id );
2160 $circ->circ_lib( $self->circ_lib );
2161 $circ->workstation($self->editor->requestor->wsid)
2162 if defined $self->editor->requestor->wsid;
2164 # renewals maintain a link to the parent circulation
2165 $circ->parent_circ($self->parent_circ);
2167 if( $self->is_renewal ) {
2168 $circ->opac_renewal('t') if $self->opac_renewal;
2169 $circ->phone_renewal('t') if $self->phone_renewal;
2170 $circ->desk_renewal('t') if $self->desk_renewal;
2171 $circ->auto_renewal('t') if $self->auto_renewal;
2172 $circ->renewal_remaining($self->renewal_remaining);
2173 $circ->auto_renewal_remaining($self->auto_renewal_remaining);
2174 $circ->circ_staff($self->editor->requestor->id);
2177 # if the user provided an overiding checkout time,
2178 # (e.g. the checkout really happened several hours ago), then
2179 # we apply that here. Does this need a perm??
2180 $circ->xact_start(clean_ISO8601($self->checkout_time))
2181 if $self->checkout_time;
2183 # if a patron is renewing, 'requestor' will be the patron
2184 $circ->circ_staff($self->editor->requestor->id);
2185 $circ->due_date( $self->create_due_date($circ->duration, $duration_date_ceiling, $duration_date_ceiling_force, $circ->xact_start) ) if $circ->duration;
2190 sub do_reservation_pickup {
2193 $self->log_me("do_reservation_pickup()");
2195 $self->reservation->pickup_time('now');
2198 $self->reservation->current_resource &&
2199 $U->is_true($self->reservation->target_resource_type->catalog_item)
2201 # We used to try to set $self->copy and $self->patron here,
2202 # but that should already be done.
2204 $self->run_checkout_scripts(1);
2206 my $duration = $self->duration_rule;
2207 my $max = $self->max_fine_rule;
2208 my $recurring = $self->recurring_fines_rule;
2210 if ($duration && $max && $recurring) {
2211 my $policy = $self->get_circ_policy($duration, $recurring, $max);
2213 my $dname = $duration->name;
2214 my $mname = $max->name;
2215 my $rname = $recurring->name;
2217 $logger->debug("circulator: updating reservation ".
2218 "with duration=$dname, maxfine=$mname, recurring=$rname");
2220 $self->reservation->fine_amount($policy->{recurring_fine});
2221 $self->reservation->max_fine($policy->{max_fine});
2222 $self->reservation->fine_interval($recurring->recurrence_interval);
2225 $self->copy->status(OILS_COPY_STATUS_CHECKED_OUT);
2226 $self->update_copy();
2229 $self->reservation->fine_amount(
2230 $self->reservation->target_resource_type->fine_amount
2232 $self->reservation->max_fine(
2233 $self->reservation->target_resource_type->max_fine
2235 $self->reservation->fine_interval(
2236 $self->reservation->target_resource_type->fine_interval
2240 $self->update_reservation();
2243 sub do_reservation_return {
2245 my $request = shift;
2247 $self->log_me("do_reservation_return()");
2249 if (not ref $self->reservation) {
2250 my ($reservation, $evt) =
2251 $U->fetch_booking_reservation($self->reservation);
2252 return $self->bail_on_events($evt) if $evt;
2253 $self->reservation($reservation);
2256 $self->handle_fines(1);
2257 $self->reservation->return_time('now');
2258 $self->update_reservation();
2259 $self->reshelve_copy if $self->copy;
2261 if ( $self->reservation->current_resource && $self->reservation->current_resource->catalog_item ) {
2262 $self->copy( $self->reservation->current_resource->catalog_item );
2266 sub booking_adjusted_due_date {
2268 my $circ = $self->circ;
2269 my $copy = $self->copy;
2271 return undef unless $self->use_booking;
2275 if( $self->due_date ) {
2277 return $self->bail_on_events($self->editor->event)
2278 unless $self->editor->allowed('CIRC_OVERRIDE_DUE_DATE', $self->circ_lib);
2280 $circ->due_date(clean_ISO8601($self->due_date));
2284 return unless $copy and $circ->due_date;
2287 my $booking_items = $self->editor->search_booking_resource( { barcode => $copy->barcode } );
2288 if (@$booking_items) {
2289 my $booking_item = $booking_items->[0];
2290 my $resource_type = $self->editor->retrieve_booking_resource_type( $booking_item->type );
2292 my $stop_circ_setting = $U->ou_ancestor_setting_value( $self->circ_lib, 'circ.booking_reservation.stop_circ', $self->editor );
2293 my $shorten_circ_setting = $resource_type->elbow_room ||
2294 $U->ou_ancestor_setting_value( $self->circ_lib, 'circ.booking_reservation.default_elbow_room', $self->editor ) ||
2297 my $booking_ses = OpenSRF::AppSession->create( 'open-ils.booking' );
2298 my $bookings = $booking_ses->request('open-ils.booking.reservations.filtered_id_list', $self->editor->authtoken, {
2299 resource => $booking_item->id
2300 , search_start => 'now'
2301 , search_end => $circ->due_date
2302 , fields => { cancel_time => undef, return_time => undef }
2304 $booking_ses->disconnect;
2306 throw OpenSRF::EX::ERROR ("Improper input arguments") unless defined $bookings;
2307 return $self->bail_on_events($bookings) if ref($bookings) eq 'HASH';
2309 my $dt_parser = DateTime::Format::ISO8601->new;
2310 my $due_date = $dt_parser->parse_datetime( clean_ISO8601($circ->due_date) );
2312 for my $bid (@$bookings) {
2314 my $booking = $self->editor->retrieve_booking_reservation( $bid );
2316 my $booking_start = $dt_parser->parse_datetime( clean_ISO8601($booking->start_time) );
2317 my $booking_end = $dt_parser->parse_datetime( clean_ISO8601($booking->end_time) );
2319 return $self->bail_on_events( OpenILS::Event->new('COPY_RESERVED') )
2320 if ($booking_start < DateTime->now);
2323 if ($U->is_true($stop_circ_setting)) {
2324 $self->bail_on_events( OpenILS::Event->new('COPY_RESERVED') );
2326 $due_date = $booking_start->subtract( seconds => interval_to_seconds($shorten_circ_setting) );
2327 $self->bail_on_events( OpenILS::Event->new('COPY_RESERVED') ) if ($due_date < DateTime->now);
2330 # We set the circ duration here only to affect the logic that will
2331 # later (in a DB trigger) mangle the time part of the due date to
2332 # 11:59pm. Having any circ duration that is not a whole number of
2333 # days is enough to prevent the "correction."
2334 my $new_circ_duration = $due_date->epoch - time;
2335 $new_circ_duration++ if $new_circ_duration % 86400 == 0;
2336 $circ->duration("$new_circ_duration seconds");
2338 $circ->due_date(clean_ISO8601($due_date->strftime('%FT%T%z')));
2342 return $self->bail_on_events($self->editor->event)
2343 unless $self->editor->allowed('CIRC_OVERRIDE_DUE_DATE', $self->circ_lib);
2349 sub apply_modified_due_date {
2351 my $shift_earlier = shift;
2352 my $circ = $self->circ;
2353 my $copy = $self->copy;
2355 if( $self->due_date ) {
2357 return $self->bail_on_events($self->editor->event)
2358 unless $self->editor->allowed('CIRC_OVERRIDE_DUE_DATE', $self->circ_lib);
2360 $circ->due_date(clean_ISO8601($self->due_date));
2364 # if the due_date lands on a day when the location is closed
2365 return unless $copy and $circ->due_date;
2367 $self->extend_renewal_due_date if $self->is_renewal;
2369 #my $org = (ref $copy->circ_lib) ? $copy->circ_lib->id : $copy->circ_lib;
2371 # due-date overlap should be determined by the location the item
2372 # is checked out from, not the owning or circ lib of the item
2373 my $org = $self->circ_lib;
2375 $logger->info("circulator: circ searching for closed date overlap on lib $org".
2376 " with an item due date of ".$circ->due_date );
2378 my $dateinfo = $U->storagereq(
2379 'open-ils.storage.actor.org_unit.closed_date.overlap',
2380 $org, $circ->due_date );
2383 $logger->info("circulator: $dateinfo : circ due data / close date overlap found : due_date=".
2384 $circ->due_date." start=". $dateinfo->{start}.", end=".$dateinfo->{end});
2386 # XXX make the behavior more dynamic
2387 # for now, we just push the due date to after the close date
2388 if ($shift_earlier) {
2389 $circ->due_date($dateinfo->{start});
2391 $circ->due_date($dateinfo->{end});
2397 sub extend_renewal_due_date {
2399 my $circ = $self->circ;
2400 my $matchpoint = $self->circ_matrix_matchpoint;
2402 return unless $U->is_true($matchpoint->renew_extends_due_date);
2404 my $prev_circ = $self->editor->retrieve_action_circulation($self->parent_circ);
2406 my $start_time = DateTime::Format::ISO8601->new
2407 ->parse_datetime(clean_ISO8601($prev_circ->xact_start))->epoch;
2409 my $prev_due_date = DateTime::Format::ISO8601->new
2410 ->parse_datetime(clean_ISO8601($prev_circ->due_date));
2412 my $due_date = DateTime::Format::ISO8601->new
2413 ->parse_datetime(clean_ISO8601($circ->due_date));
2415 my $prev_due_time = $prev_due_date->epoch;
2417 my $now_time = DateTime->now->epoch;
2419 return if $prev_due_time < $now_time; # Renewed circ was overdue.
2421 if (my $interval = $matchpoint->renew_extend_min_interval) {
2423 my $min_duration = OpenILS::Utils::DateTime->interval_to_seconds($interval);
2424 my $checkout_duration = $now_time - $start_time;
2426 if ($checkout_duration < $min_duration) {
2427 # Renewal occurred too early in the cycle to result in an
2428 # extension of the due date on the renewal.
2430 # If the new due date falls before the due date of
2431 # the previous circulation, use the due date of the prev.
2432 # circ so the patron does not lose time.
2433 my $due = $due_date < $prev_due_date ? $prev_due_date : $due_date;
2434 $circ->due_date($due->strftime('%FT%T%z'));
2440 # Item was checked out long enough during the previous circulation
2441 # to consider extending the due date of the renewal to cover the gap.
2443 # Amount of the previous duration that was left unused.
2444 my $remaining_duration = $prev_due_time - $now_time;
2446 $due_date->add(seconds => $remaining_duration);
2448 # If the calculated due date falls before the due date of the previous
2449 # circulation, use the due date of the prev. circ so the patron does
2451 my $due = $due_date < $prev_due_date ? $prev_due_date : $due_date;
2453 $logger->info("circulator: renewal due date extension landed on due date: $due");
2455 $circ->due_date($due->strftime('%FT%T%z'));
2459 sub create_due_date {
2460 my( $self, $duration, $date_ceiling, $force_date, $start_time ) = @_;
2462 # Look up circulating library's TZ, or else use client TZ, falling
2464 my $tz = $U->ou_ancestor_setting_value(
2470 my $due_date = $start_time ?
2471 DateTime::Format::ISO8601
2473 ->parse_datetime(clean_ISO8601($start_time))
2474 ->set_time_zone($tz) :
2475 DateTime->now(time_zone => $tz);
2477 # add the circ duration
2478 $due_date->add(seconds => OpenILS::Utils::DateTime->interval_to_seconds($duration, $due_date));
2481 my $cdate = DateTime::Format::ISO8601
2483 ->parse_datetime(clean_ISO8601($date_ceiling))
2484 ->set_time_zone($tz);
2486 if ($cdate > DateTime->now and ($cdate < $due_date or $U->is_true( $force_date ))) {
2487 $logger->info("circulator: overriding due date with date ceiling: $date_ceiling");
2492 # return ISO8601 time with timezone
2493 return $due_date->strftime('%FT%T%z');
2498 sub make_precat_copy {
2500 my $copy = $self->copy;
2501 return $self->bail_on_events(OpenILS::Event->new('PERM_FAILURE'))
2502 unless $self->editor->allowed('CREATE_PRECAT') || $self->is_renewal;
2505 $logger->debug("circulator: Pre-cat copy already exists in checkout: ID=" . $copy->id);
2507 $copy->editor($self->editor->requestor->id);
2508 $copy->edit_date('now');
2509 $copy->dummy_title($self->dummy_title || $copy->dummy_title || '');
2510 $copy->dummy_isbn($self->dummy_isbn || $copy->dummy_isbn || '');
2511 $copy->dummy_author($self->dummy_author || $copy->dummy_author || '');
2512 $copy->circ_modifier($self->circ_modifier || $copy->circ_modifier);
2513 $self->update_copy();
2517 $logger->info("circulator: Creating a new precataloged ".
2518 "copy in checkout with barcode " . $self->copy_barcode);
2520 $copy = Fieldmapper::asset::copy->new;
2521 $copy->circ_lib($self->circ_lib);
2522 $copy->creator($self->editor->requestor->id);
2523 $copy->editor($self->editor->requestor->id);
2524 $copy->barcode($self->copy_barcode);
2525 $copy->call_number(OILS_PRECAT_CALL_NUMBER);
2526 $copy->loan_duration(OILS_PRECAT_COPY_LOAN_DURATION);
2527 $copy->fine_level(OILS_PRECAT_COPY_FINE_LEVEL);
2529 $copy->dummy_title($self->dummy_title || "");
2530 $copy->dummy_author($self->dummy_author || "");
2531 $copy->dummy_isbn($self->dummy_isbn || "");
2532 $copy->circ_modifier($self->circ_modifier);
2535 # See if we need to override the circ_lib for the copy with a configured circ_lib
2536 # Setting is shortname of the org unit
2537 my $precat_circ_lib = $U->ou_ancestor_setting_value(
2538 $self->circ_lib, 'circ.pre_cat_copy_circ_lib', $self->editor);
2540 if($precat_circ_lib) {
2541 my $org = $self->editor->search_actor_org_unit({shortname => $precat_circ_lib})->[0];
2544 $self->bail_on_events($self->editor->event);
2548 $copy->circ_lib($org->id);
2552 unless( $self->copy($self->editor->create_asset_copy($copy)) ) {
2554 $self->push_events($self->editor->event);
2560 sub checkout_noncat {
2566 my $lib = $self->noncat_circ_lib || $self->circ_lib;
2567 my $count = $self->noncat_count || 1;
2568 my $cotime = clean_ISO8601($self->checkout_time) || "";
2570 $logger->info("circulator: circ creating $count noncat circs with checkout time $cotime");
2574 ( $circ, $evt ) = OpenILS::Application::Circ::NonCat::create_non_cat_circ(
2575 $self->editor->requestor->id,
2583 $self->push_events($evt);
2591 # if an item is in transit but the status doesn't agree, then we need to fix things.
2592 # The next two subs will hopefully do that
2593 sub fix_broken_transit_status {
2596 # Capture the transit so we don't have to fetch it again later during checkin
2597 # This used to live in sub check_transit_checkin_interval and later again in
2600 $self->editor->search_action_transit_copy(
2601 {target_copy => $self->copy->id, dest_recv_time => undef, cancel_time => undef}
2605 if ($self->transit && $U->copy_status($self->copy->status)->id != OILS_COPY_STATUS_IN_TRANSIT) {
2606 $logger->warn("circulator: we have a copy ".$self->copy->barcode.
2607 " that is in-transit but without the In Transit status... fixing");
2608 $self->copy->status(OILS_COPY_STATUS_IN_TRANSIT);
2609 # FIXME - do we want to make this permanent if the checkin bails?
2614 sub cancel_transit_if_circ_exists {
2616 if ($self->circ && $self->transit) {
2617 $logger->warn("circulator: we have a copy ".$self->copy->barcode.
2618 " that is in-transit AND circulating... aborting the transit");
2619 my $circ_ses = create OpenSRF::AppSession("open-ils.circ");
2620 my $result = $circ_ses->request(
2621 "open-ils.circ.transit.abort",
2622 $self->editor->authtoken,
2623 { 'transitid' => $self->transit->id }
2625 $logger->warn("circulator: transit abort result: ".$result);
2626 $circ_ses->disconnect;
2627 $self->transit(undef);
2631 # If a copy goes into transit and is then checked in before the transit checkin
2632 # interval has expired, push an event onto the overridable events list.
2633 sub check_transit_checkin_interval {
2636 # only concerned with in-transit items
2637 return unless $U->copy_status($self->copy->status)->id == OILS_COPY_STATUS_IN_TRANSIT;
2639 # no interval, no problem
2640 my $interval = $U->ou_ancestor_setting_value($self->circ_lib, 'circ.transit.min_checkin_interval');
2641 return unless $interval;
2643 # transit from X to X for whatever reason has no min interval
2644 return if $self->transit->source == $self->transit->dest;
2646 my $seconds = OpenILS::Utils::DateTime->interval_to_seconds($interval);
2647 my $t_start = DateTime::Format::ISO8601->new->parse_datetime(clean_ISO8601($self->transit->source_send_time));
2648 my $horizon = $t_start->add(seconds => $seconds);
2650 # See if we are still within the transit checkin forbidden range
2651 $self->push_events(OpenILS::Event->new('TRANSIT_CHECKIN_INTERVAL_BLOCK'))
2652 if $horizon > DateTime->now;
2655 # Retarget local holds at checkin
2656 sub checkin_retarget {
2658 return unless $self->retarget_mode and $self->retarget_mode =~ m/retarget/; # Retargeting?
2659 return unless $self->is_checkin; # Renewals need not be checked
2660 return if $self->capture eq 'nocapture'; # Not capturing holds anyway? Move on.
2661 return if $self->is_precat; # No holds for precats
2662 return unless $self->circ_lib == $self->copy->circ_lib; # Item isn't "home"? Don't check.
2663 return unless $U->is_true($self->copy->holdable); # Not holdable, shouldn't capture holds.
2664 my $status = $U->copy_status($self->copy->status);
2665 return unless $U->is_true($status->holdable); # Current status not holdable means no hold will ever target the item
2666 # Specifically target items that are likely new (by status ID)
2667 return unless $status->id == OILS_COPY_STATUS_IN_PROCESS || $self->retarget_mode =~ m/\.all/;
2668 my $location = $self->copy->location;
2669 if(!ref($location)) {
2670 $location = $self->editor->retrieve_asset_copy_location($self->copy->location);
2671 $self->copy->location($location);
2673 return unless $U->is_true($location->holdable); # Don't bother on non-holdable locations
2675 # Fetch holds for the bib
2676 my ($result) = $holdcode->method_lookup('open-ils.circ.holds.retrieve_all_from_title')->run(
2677 $self->editor->authtoken,
2680 capture_time => undef, # No touching captured holds
2681 frozen => 'f', # Don't bother with frozen holds
2682 pickup_lib => $self->circ_lib # Only holds actually here
2685 # Error? Skip the step.
2686 return if exists $result->{"ilsevent"};
2690 foreach my $holdlist (keys %{$result}) {
2691 push @$holds, @{$result->{$holdlist}};
2694 return if scalar(@$holds) == 0; # No holds, no retargeting
2696 # Check for parts on this copy
2697 my $parts = $self->editor->search_asset_copy_part_map({ target_copy => $self->copy->id });
2698 my %parts_hash = ();
2699 %parts_hash = map {$_->part, 1} @$parts if @$parts;
2701 # Loop over holds in request-ish order
2702 # Stage 1: Get them into request-ish order
2703 # Also grab type and target for skipping low hanging ones
2704 $result = $self->editor->json_query({
2705 "select" => { "ahr" => ["id", "hold_type", "target"] },
2706 "from" => { "ahr" => { "au" => { "fkey" => "usr", "join" => "pgt"} } },
2707 "where" => { "id" => $holds },
2709 { "class" => "pgt", "field" => "hold_priority"},
2710 { "class" => "ahr", "field" => "cut_in_line", "direction" => "desc", "transform" => "coalesce", "params" => ['f']},
2711 { "class" => "ahr", "field" => "selection_depth", "direction" => "desc"},
2712 { "class" => "ahr", "field" => "request_time"}
2717 if (ref $result eq "ARRAY" and scalar @$result) {
2718 foreach (@{$result}) {
2719 # Copy level, but not this copy?
2720 next if ($_->{hold_type} eq 'C' or $_->{hold_type} eq 'R' or $_->{hold_type} eq 'F'
2721 and $_->{target} != $self->copy->id);
2722 # Volume level, but not this volume?
2723 next if ($_->{hold_type} eq 'V' and $_->{target} != $self->volume->id);
2724 if(@$parts) { # We have parts?
2726 next if ($_->{hold_type} eq 'T');
2727 # Skip part holds for parts not on this copy
2728 next if ($_->{hold_type} eq 'P' and not $parts_hash{$_->{target}});
2730 # No parts, no part holds
2731 next if ($_->{hold_type} eq 'P');
2733 # So much for easy stuff, attempt a retarget!
2734 my $tresult = $U->simplereq(
2735 'open-ils.hold-targeter',
2736 'open-ils.hold-targeter.target',
2737 {hold => $_->{id}, find_copy => $self->copy->id}
2739 if(ref $tresult eq "ARRAY" and scalar @$tresult) {
2740 last if(exists $tresult->[0]->{found_copy} and $tresult->[0]->{found_copy});
2748 $self->log_me("do_checkin()");
2750 return $self->bail_on_events(
2751 OpenILS::Event->new('ASSET_COPY_NOT_FOUND'))
2754 # Never capture a deleted copy for a hold.
2755 $self->capture('nocapture') if $U->is_true($self->copy->deleted);
2757 $self->fix_broken_transit_status; # if applicable
2758 $self->check_transit_checkin_interval;
2759 $self->checkin_retarget;
2761 # the renew code and mk_env should have already found our circulation object
2762 unless( $self->circ ) {
2764 my $circs = $self->editor->search_action_circulation(
2765 { target_copy => $self->copy->id, checkin_time => undef });
2767 $self->circ($$circs[0]);
2769 # for now, just warn if there are multiple open circs on a copy
2770 $logger->warn("circulator: we have ".scalar(@$circs).
2771 " open circs for copy " .$self->copy->id."!!") if @$circs > 1;
2773 $self->cancel_transit_if_circ_exists; # if applicable
2775 my $stat = $U->copy_status($self->copy->status)->id;
2777 # LOST (and to some extent, LONGOVERDUE) may optionally be handled
2778 # differently if they are already paid for. We need to check for this
2779 # early since overdue generation is potentially affected.
2780 my $dont_change_lost_zero = 0;
2781 if ($stat == OILS_COPY_STATUS_LOST
2782 || $stat == OILS_COPY_STATUS_LOST_AND_PAID
2783 || $stat == OILS_COPY_STATUS_LONG_OVERDUE) {
2785 # LOST fine settings are controlled by the copy's circ lib, not the the
2787 my $copy_circ_lib = (ref $self->copy->circ_lib) ?
2788 $self->copy->circ_lib->id : $self->copy->circ_lib;
2789 $dont_change_lost_zero = $U->ou_ancestor_setting_value(
2790 $copy_circ_lib, 'circ.checkin.lost_zero_balance.do_not_change',
2791 $self->editor) || 0;
2793 # Don't assume there's always a circ based on copy status
2794 if ($dont_change_lost_zero && $self->circ) {
2795 my ($obt) = $U->fetch_mbts($self->circ->id, $self->editor);
2796 $dont_change_lost_zero = 0 if( $obt and $obt->balance_owed != 0 );
2799 $self->dont_change_lost_zero($dont_change_lost_zero);
2802 # Check if the copy can float to here. We need this for inventory
2803 # and to see if the copy needs to transit or stay here later.
2805 if ($self->copy->floating) {
2806 my $res = $self->editor->json_query(
2809 'evergreen.can_float',
2810 $self->copy->floating->id,
2811 $self->copy->circ_lib,
2816 $can_float = $U->is_true($res->[0]->{'evergreen.can_float'}) if $res;
2819 # Do copy inventory if necessary.
2820 if ($self->do_inventory_update && ($self->circ_lib == $self->copy->circ_lib || $can_float)) {
2821 my $aci = Fieldmapper::asset::copy_inventory->new();
2822 $aci->inventory_date('now');
2823 $aci->inventory_workstation($self->editor->requestor->wsid);
2824 $aci->copy($self->copy->id());
2825 $self->editor->create_asset_copy_inventory($aci);
2826 $self->checkin_changed(1);
2829 if( $self->checkin_check_holds_shelf() ) {
2830 $self->bail_on_events(OpenILS::Event->new('NO_CHANGE'));
2831 $self->hold($U->fetch_open_hold_by_copy($self->copy->id));
2832 if($self->fake_hold_dest) {
2833 $self->hold->pickup_lib($self->circ_lib);
2835 $self->checkin_flesh_events;
2839 unless( $self->is_renewal ) {
2840 return $self->bail_on_events($self->editor->event)
2841 unless $self->editor->allowed('COPY_CHECKIN');
2844 $self->push_events($self->check_copy_alert());
2845 $self->push_events($self->check_checkin_copy_status());
2847 # if the circ is marked as 'claims returned', add the event to the list
2848 $self->push_events(OpenILS::Event->new('CIRC_CLAIMS_RETURNED'))
2849 if ($self->circ and $self->circ->stop_fines
2850 and $self->circ->stop_fines eq OILS_STOP_FINES_CLAIMSRETURNED);
2852 $self->check_circ_deposit();
2854 # handle the overridable events
2855 $self->override_events unless $self->is_renewal;
2856 return if $self->bail_out;
2859 $self->checkin_handle_circ_start;
2860 return if $self->bail_out;
2862 if (!$dont_change_lost_zero) {
2863 # if this circ is LOST and we are configured to generate overdue
2864 # fines for lost items on checkin (to fill the gap between mark
2865 # lost time and when the fines would have naturally stopped), then
2866 # stop_fines is no longer valid and should be cleared.
2868 # stop_fines will be set again during the handle_fines() stage.
2869 # XXX should this setting come from the copy circ lib (like other
2870 # LOST settings), instead of the circulation circ lib?
2871 if ($stat == OILS_COPY_STATUS_LOST) {
2872 $self->circ->clear_stop_fines if
2873 $U->ou_ancestor_setting_value(
2875 OILS_SETTING_GENERATE_OVERDUE_ON_LOST_RETURN,
2880 # Set stop_fines when claimed never checked out
2881 $self->circ->stop_fines( OILS_STOP_FINES_CLAIMS_NEVERCHECKEDOUT ) if( $self->claims_never_checked_out );
2883 # handle fines for this circ, including overdue gen if needed
2884 $self->handle_fines;
2887 # Void any item deposits if the library wants to
2888 $self->check_circ_deposit(1);
2890 $self->checkin_handle_circ_finish;
2891 return if $self->bail_out;
2892 $self->checkin_changed(1);
2894 } elsif( $self->transit ) {
2895 my $hold_transit = $self->process_received_transit;
2896 $self->checkin_changed(1);
2898 if( $self->bail_out ) {
2899 $self->checkin_flesh_events;
2903 if( my $e = $self->check_checkin_copy_status() ) {
2904 # If the original copy status is special, alert the caller
2905 my $ev = $self->events;
2906 $self->events([$e]);
2907 $self->override_events;
2908 return if $self->bail_out;
2912 if( $hold_transit or
2913 $U->copy_status($self->copy->status)->id
2914 == OILS_COPY_STATUS_ON_HOLDS_SHELF ) {
2917 if( $hold_transit ) {
2918 $hold = $self->editor->retrieve_action_hold_request($hold_transit->hold);
2920 ($hold) = $U->fetch_open_hold_by_copy($self->copy->id);
2925 if( $hold and ( $hold->cancel_time or $hold->fulfillment_time ) ) { # this transited hold was cancelled or filled mid-transit
2927 $logger->info("circulator: we received a transit on a cancelled or filled hold " . $hold->id);
2928 $self->reshelve_copy(1);
2929 $self->cancelled_hold_transit(1);
2930 $self->notify_hold(0); # don't notify for cancelled holds
2931 $self->fake_hold_dest(0);
2932 return if $self->bail_out;
2934 } elsif ($hold and $hold->hold_type eq 'R') {
2936 $self->copy->status(OILS_COPY_STATUS_CATALOGING);
2937 $self->notify_hold(0); # No need to notify
2938 $self->fake_hold_dest(0);
2939 $self->noop(1); # Don't try and capture for other holds/transits now
2940 $self->update_copy();
2941 $hold->fulfillment_time('now');
2942 $self->bail_on_events($self->editor->event)
2943 unless $self->editor->update_action_hold_request($hold);
2947 # hold transited to correct location
2948 if($self->fake_hold_dest) {
2949 $hold->pickup_lib($self->circ_lib);
2951 $self->checkin_flesh_events;
2956 } elsif( $U->copy_status($self->copy->status)->id == OILS_COPY_STATUS_IN_TRANSIT ) {
2958 $logger->warn("circulator: we have a copy ".$self->copy->barcode.
2959 " that is in-transit, but there is no transit.. repairing");
2960 $self->reshelve_copy(1);
2961 return if $self->bail_out;
2964 if( $self->is_renewal ) {
2965 $self->finish_fines_and_voiding;
2966 return if $self->bail_out;
2967 $self->push_events(OpenILS::Event->new('SUCCESS'));
2971 # ------------------------------------------------------------------------------
2972 # Circulations and transits are now closed where necessary. Now go on to see if
2973 # this copy can fulfill a hold or needs to be routed to a different location
2974 # ------------------------------------------------------------------------------
2976 my $needed_for_something = 0; # formerly "needed_for_hold"
2978 if(!$self->noop) { # /not/ a no-op checkin, capture for hold or put item into transit
2980 if (!$self->remote_hold) {
2981 if ($self->use_booking) {
2982 my $potential_hold = $self->hold_capture_is_possible;
2983 my $potential_reservation = $self->reservation_capture_is_possible;
2985 if ($potential_hold and $potential_reservation) {
2986 $logger->info("circulator: item could fulfill either hold or reservation");
2987 $self->push_events(new OpenILS::Event(
2988 "HOLD_RESERVATION_CONFLICT",
2989 "hold" => $potential_hold,
2990 "reservation" => $potential_reservation
2992 return if $self->bail_out;
2993 } elsif ($potential_hold) {
2994 $needed_for_something =
2995 $self->attempt_checkin_hold_capture;
2996 } elsif ($potential_reservation) {
2997 $needed_for_something =
2998 $self->attempt_checkin_reservation_capture;
3001 $needed_for_something = $self->attempt_checkin_hold_capture;
3004 return if $self->bail_out;
3006 unless($needed_for_something) {
3007 my $circ_lib = (ref $self->copy->circ_lib) ?
3008 $self->copy->circ_lib->id : $self->copy->circ_lib;
3010 if( $self->remote_hold ) {
3011 $circ_lib = $self->remote_hold->pickup_lib;
3012 $logger->warn("circulator: Copy ".$self->copy->barcode.
3013 " is on a remote hold's shelf, sending to $circ_lib");
3016 $logger->debug("circulator: circlib=$circ_lib, workstation=".$self->circ_lib);
3018 my $suppress_transit = 0;
3020 if( $circ_lib != $self->circ_lib and not ($self->hold_as_transit and $self->remote_hold) ) {
3021 my $suppress_transit_source = $U->ou_ancestor_setting($self->circ_lib, 'circ.transit.suppress_non_hold');
3022 if($suppress_transit_source && $suppress_transit_source->{value}) {
3023 my $suppress_transit_dest = $U->ou_ancestor_setting($circ_lib, 'circ.transit.suppress_non_hold');
3024 if($suppress_transit_dest && $suppress_transit_source->{value} eq $suppress_transit_dest->{value}) {
3025 $logger->info("circulator: copy is within transit suppress group: ".$self->copy->barcode." ".$suppress_transit_source->{value});
3026 $suppress_transit = 1;
3031 if( $suppress_transit or ( $circ_lib == $self->circ_lib and not ($self->hold_as_transit and $self->remote_hold) ) ) {
3032 # copy is where it needs to be, either for hold or reshelving
3034 $self->checkin_handle_precat();
3035 return if $self->bail_out;
3038 # copy needs to transit "home", or stick here if it's a floating copy
3039 if ($can_float && ($self->manual_float || !$U->is_true($self->copy->floating->manual)) && !$self->remote_hold) { # Yep, floating, stick here
3040 $self->checkin_changed(1);
3041 $self->copy->circ_lib( $self->circ_lib );
3044 my $bc = $self->copy->barcode;
3045 $logger->info("circulator: copy $bc at the wrong location, sending to $circ_lib");
3046 $self->checkin_build_copy_transit($circ_lib);
3047 return if $self->bail_out;
3048 $self->push_events(OpenILS::Event->new('ROUTE_ITEM', org => $circ_lib));
3052 } else { # no-op checkin
3053 # XXX floating items still stick where they are even with no-op checkin?
3054 if ($self->copy->floating && $can_float) {
3055 $self->checkin_changed(1);
3056 $self->copy->circ_lib( $self->circ_lib );
3061 if($self->claims_never_checked_out and
3062 $U->ou_ancestor_setting_value($self->circ->circ_lib, 'circ.claim_never_checked_out.mark_missing')) {
3064 # the item was not supposed to be checked out to the user and should now be marked as missing
3065 my $next_status = $self->next_copy_status->[0] || OILS_COPY_STATUS_MISSING;
3066 $self->copy->status($next_status);
3070 $self->reshelve_copy unless $needed_for_something;
3073 return if $self->bail_out;
3075 unless($self->checkin_changed) {
3077 $self->push_events(OpenILS::Event->new('NO_CHANGE'));
3078 my $stat = $U->copy_status($self->copy->status)->id;
3080 $self->hold($U->fetch_open_hold_by_copy($self->copy->id))
3081 if( $stat == OILS_COPY_STATUS_ON_HOLDS_SHELF );
3082 $self->bail_out(1); # no need to commit anything
3086 $self->push_events(OpenILS::Event->new('SUCCESS'))
3087 unless @{$self->events};
3090 $self->finish_fines_and_voiding;
3092 OpenILS::Utils::Penalty->calculate_penalties(
3093 $self->editor, $self->patron->id, $self->circ_lib) if $self->patron;
3095 $self->checkin_flesh_events;
3099 sub finish_fines_and_voiding {
3101 return unless $self->circ;
3103 return unless $self->backdate or $self->void_overdues;
3105 # void overdues after fine generation to prevent concurrent DB access to overdue billings
3106 my $note = 'System: Amnesty Checkin' if $self->void_overdues;
3108 my $evt = $CC->void_or_zero_overdues(
3109 $self->editor, $self->circ, {backdate => $self->void_overdues ? undef : $self->backdate, note => $note});
3111 return $self->bail_on_events($evt) if $evt;
3113 # Make sure the circ is open or closed as necessary.
3114 $evt = $U->check_open_xact($self->editor, $self->circ->id);
3115 return $self->bail_on_events($evt) if $evt;
3121 # if a deposit was paid for this item, push the event
3122 # if called with a truthy param perform the void, depending on settings
3123 sub check_circ_deposit {
3127 return unless $self->circ;
3129 my $deposit = $self->editor->search_money_billing(
3131 xact => $self->circ->id,
3133 }, {idlist => 1})->[0];
3135 return unless $deposit;
3138 my $void_on_checkin = $U->ou_ancestor_setting_value(
3139 $self->circ_lib,OILS_SETTING_VOID_ITEM_DEPOSIT_ON_CHECKIN,$self->editor);
3140 if ( $void_on_checkin ) {
3141 my $evt = $CC->void_bills($self->editor,[$deposit], "DEPOSIT ITEM RETURNED");
3142 return $evt if $evt;
3144 } else { # if void is unset this is just a check, notify that there was a deposit billing
3145 $self->push_events(OpenILS::Event->new('ITEM_DEPOSIT_PAID', payload => $deposit));
3151 my $force = $self->force || shift;
3152 my $copy = $self->copy;
3154 my $stat = $U->copy_status($copy->status)->id;
3156 my $next_status = $self->next_copy_status->[0] || OILS_COPY_STATUS_RESHELVING;
3159 $stat != OILS_COPY_STATUS_ON_HOLDS_SHELF and
3160 $stat != OILS_COPY_STATUS_CATALOGING and
3161 $stat != OILS_COPY_STATUS_IN_TRANSIT and
3162 $stat != $next_status )) {
3164 $copy->status( $next_status );
3166 $self->checkin_changed(1);
3171 # Returns true if the item is at the current location
3172 # because it was transited there for a hold and the
3173 # hold has not been fulfilled
3174 sub checkin_check_holds_shelf {
3176 return 0 unless $self->copy;
3179 $U->copy_status($self->copy->status)->id ==
3180 OILS_COPY_STATUS_ON_HOLDS_SHELF;
3182 # Attempt to clear shelf expired holds for this copy
3183 $holdcode->method_lookup('open-ils.circ.hold.clear_shelf.process')->run($self->editor->authtoken, $self->circ_lib, $self->copy->id)
3184 if($self->clear_expired);
3186 # find the hold that put us on the holds shelf
3187 my $holds = $self->editor->search_action_hold_request(
3189 current_copy => $self->copy->id,
3190 capture_time => { '!=' => undef },
3191 fulfillment_time => undef,
3192 cancel_time => undef,
3197 $logger->warn("circulator: copy is on-holds-shelf, but there is no hold - reshelving");
3198 $self->reshelve_copy(1);
3202 my $hold = $$holds[0];
3204 $logger->info("circulator: we found a captured, un-fulfilled hold [".
3205 $hold->id. "] for copy ".$self->copy->barcode);
3207 if( $hold->pickup_lib != $self->circ_lib and not $self->hold_as_transit ) {
3208 my $suppress_transit_circ = $U->ou_ancestor_setting($self->circ_lib, 'circ.transit.suppress_hold');
3209 if($suppress_transit_circ && $suppress_transit_circ->{value}) {
3210 my $suppress_transit_pickup = $U->ou_ancestor_setting($hold->pickup_lib, 'circ.transit.suppress_hold');
3211 if($suppress_transit_pickup && $suppress_transit_circ->{value} eq $suppress_transit_pickup->{value}) {
3212 $logger->info("circulator: hold is within hold transit suppress group .. we're done: ".$self->copy->barcode." ".$suppress_transit_circ->{value});
3213 $self->fake_hold_dest(1);
3219 if( $hold->pickup_lib == $self->circ_lib and not $self->hold_as_transit ) {
3220 $logger->info("circulator: hold is for here .. we're done: ".$self->copy->barcode);
3224 $logger->info("circulator: hold is not for here..");
3225 $self->remote_hold($hold);
3230 sub checkin_handle_precat {
3232 my $copy = $self->copy;
3234 if( $self->is_precat and ($copy->status != OILS_COPY_STATUS_CATALOGING) ) {
3235 $copy->status(OILS_COPY_STATUS_CATALOGING);
3236 $self->update_copy();
3237 $self->checkin_changed(1);
3238 $self->push_events(OpenILS::Event->new('ITEM_NOT_CATALOGED'));
3243 sub checkin_build_copy_transit {
3246 my $copy = $self->copy;
3247 my $transit = Fieldmapper::action::transit_copy->new;
3249 # if we are transiting an item to the shelf shelf, it's a hold transit
3250 if (my $hold = $self->remote_hold) {
3251 $transit = Fieldmapper::action::hold_transit_copy->new;
3252 $transit->hold($hold->id);
3254 # the item is going into transit, remove any shelf-iness
3255 if ($hold->current_shelf_lib or $hold->shelf_time) {
3256 $hold->clear_current_shelf_lib;
3257 $hold->clear_shelf_time;
3258 return $self->bail_on_events($self->editor->event)
3259 unless $self->editor->update_action_hold_request($hold);
3263 #$dest ||= (ref($copy->circ_lib)) ? $copy->circ_lib->id : $copy->circ_lib;
3264 $logger->info("circulator: transiting copy to $dest");
3266 $transit->source($self->circ_lib);
3267 $transit->dest($dest);
3268 $transit->target_copy($copy->id);
3269 $transit->source_send_time('now');
3270 $transit->copy_status( $U->copy_status($copy->status)->id );
3272 $logger->debug("circulator: setting copy status on transit: ".$transit->copy_status);
3274 if ($self->remote_hold) {
3275 return $self->bail_on_events($self->editor->event)
3276 unless $self->editor->create_action_hold_transit_copy($transit);
3278 return $self->bail_on_events($self->editor->event)
3279 unless $self->editor->create_action_transit_copy($transit);
3282 # ensure the transit is returned to the caller
3283 $self->transit($transit);
3285 $copy->status(OILS_COPY_STATUS_IN_TRANSIT);
3287 $self->checkin_changed(1);
3291 sub hold_capture_is_possible {
3293 my $copy = $self->copy;
3295 # we've been explicitly told not to capture any holds
3296 return 0 if $self->capture eq 'nocapture';
3298 # See if this copy can fulfill any holds
3299 my $hold = $holdcode->find_nearest_permitted_hold(
3300 $self->editor, $copy, $self->editor->requestor, 1 # check_only
3302 return undef if ref $hold eq "HASH" and
3303 $hold->{"textcode"} eq "ACTION_HOLD_REQUEST_NOT_FOUND";
3307 sub reservation_capture_is_possible {
3309 my $copy = $self->copy;
3311 # we've been explicitly told not to capture any holds
3312 return 0 if $self->capture eq 'nocapture';
3314 my $booking_ses = OpenSRF::AppSession->connect("open-ils.booking");
3315 my $resv = $booking_ses->request(
3316 "open-ils.booking.reservations.could_capture",
3317 $self->editor->authtoken, $copy->barcode
3319 $booking_ses->disconnect;
3320 if (ref($resv) eq "HASH" and exists $resv->{"textcode"}) {
3321 $self->push_events($resv);
3327 # returns true if the item was used (or may potentially be used
3328 # in subsequent calls) to capture a hold.
3329 sub attempt_checkin_hold_capture {
3331 my $copy = $self->copy;
3333 # we've been explicitly told not to capture any holds
3334 return 0 if $self->capture eq 'nocapture';
3336 # See if this copy can fulfill any holds
3337 my ($hold, undef, $retarget) = $holdcode->find_nearest_permitted_hold(
3338 $self->editor, $copy, $self->editor->requestor );
3341 $logger->debug("circulator: no potential permitted".
3342 "holds found for copy ".$copy->barcode);
3346 if($self->capture ne 'capture') {
3347 # see if this item is in a hold-capture-delay location
3348 my $location = $self->copy->location;
3349 if(!ref($location)) {
3350 $location = $self->editor->retrieve_asset_copy_location($self->copy->location);
3351 $self->copy->location($location);
3353 if($U->is_true($location->hold_verify)) {
3354 $self->bail_on_events(
3355 OpenILS::Event->new('HOLD_CAPTURE_DELAYED', copy_location => $location));
3360 $self->retarget($retarget);
3362 my $suppress_transit = 0;
3363 if( $hold->pickup_lib != $self->circ_lib and not $self->hold_as_transit ) {
3364 my $suppress_transit_circ = $U->ou_ancestor_setting($self->circ_lib, 'circ.transit.suppress_hold');
3365 if($suppress_transit_circ && $suppress_transit_circ->{value}) {
3366 my $suppress_transit_pickup = $U->ou_ancestor_setting($hold->pickup_lib, 'circ.transit.suppress_hold');
3367 if($suppress_transit_pickup && $suppress_transit_circ->{value} eq $suppress_transit_pickup->{value}) {
3368 $suppress_transit = 1;
3369 $hold->pickup_lib($self->circ_lib);
3374 $logger->info("circulator: found permitted hold ".$hold->id." for copy, capturing...");
3376 $hold->clear_hopeless_date;
3377 $hold->current_copy($copy->id);
3378 $hold->capture_time('now');
3379 $self->put_hold_on_shelf($hold)
3380 if ($suppress_transit || ($hold->pickup_lib == $self->circ_lib and not $self->hold_as_transit) );
3382 # prevent DB errors caused by fetching
3383 # holds from storage, and updating through cstore
3384 $hold->clear_fulfillment_time;
3385 $hold->clear_fulfillment_staff;
3386 $hold->clear_fulfillment_lib;
3387 $hold->clear_expire_time;
3388 $hold->clear_cancel_time;
3389 $hold->clear_prev_check_time unless $hold->prev_check_time;
3391 $self->bail_on_events($self->editor->event)
3392 unless $self->editor->update_action_hold_request($hold);
3394 $self->checkin_changed(1);
3396 return 0 if $self->bail_out;
3398 if( $suppress_transit or ( $hold->pickup_lib == $self->circ_lib && not $self->hold_as_transit ) ) {
3400 if ($hold->hold_type eq 'R') {
3401 $copy->status(OILS_COPY_STATUS_CATALOGING);
3402 $hold->fulfillment_time('now');
3403 $self->noop(1); # Block other transit/hold checks
3404 $self->bail_on_events($self->editor->event)
3405 unless $self->editor->update_action_hold_request($hold);
3407 # This hold was captured in the correct location
3408 $copy->status(OILS_COPY_STATUS_ON_HOLDS_SHELF);
3409 $self->push_events(OpenILS::Event->new('SUCCESS'));
3411 #$self->do_hold_notify($hold->id);
3412 $self->notify_hold($hold->id);
3417 # Hold needs to be picked up elsewhere. Build a hold
3418 # transit and route the item.
3419 $self->checkin_build_hold_transit();
3420 $copy->status(OILS_COPY_STATUS_IN_TRANSIT);
3421 return 0 if $self->bail_out;
3422 $self->push_events(OpenILS::Event->new('ROUTE_ITEM', org => $hold->pickup_lib));
3425 # make sure we save the copy status
3427 return 0 if $copy->status == OILS_COPY_STATUS_CATALOGING;
3431 sub attempt_checkin_reservation_capture {
3433 my $copy = $self->copy;
3435 # we've been explicitly told not to capture any holds
3436 return 0 if $self->capture eq 'nocapture';
3438 my $booking_ses = OpenSRF::AppSession->connect("open-ils.booking");
3439 my $evt = $booking_ses->request(
3440 "open-ils.booking.resources.capture_for_reservation",
3441 $self->editor->authtoken,
3443 1 # don't update copy - we probably have it locked
3445 $booking_ses->disconnect;
3447 if (ref($evt) ne "HASH" or not exists $evt->{"textcode"}) {
3449 "open-ils.booking.resources.capture_for_reservation " .
3450 "didn't return an event!"
3454 $evt->{"textcode"} eq "RESERVATION_NOT_FOUND" and
3455 $evt->{"payload"}->{"fail_cause"} eq "not-transferable"
3457 # not-transferable is an error event we'll pass on the user
3458 $logger->warn("reservation capture attempted against non-transferable item");
3459 $self->push_events($evt);
3461 } elsif ($evt->{"textcode"} eq "SUCCESS") {
3462 # Re-retrieve copy as reservation capture may have changed
3463 # its status and whatnot.
3465 "circulator: booking capture win on copy " . $self->copy->id
3467 if (my $new_copy_status = $evt->{"payload"}->{"new_copy_status"}) {
3469 "circulator: changing copy " . $self->copy->id .
3470 "'s status from " . $self->copy->status . " to " .
3473 $self->copy->status($new_copy_status);
3476 $self->reservation($evt->{"payload"}->{"reservation"});
3478 if (exists $evt->{"payload"}->{"transit"}) {
3482 "org" => $evt->{"payload"}->{"transit"}->dest
3486 $self->checkin_changed(1);
3490 # other results are treated as "nothing to capture"
3494 sub do_hold_notify {
3495 my( $self, $holdid ) = @_;
3497 my $e = new_editor(xact => 1);
3498 my $hold = $e->retrieve_action_hold_request($holdid) or return $e->die_event;
3500 my $ses = OpenSRF::AppSession->create('open-ils.trigger');
3501 $ses->request('open-ils.trigger.event.autocreate', 'hold.available', $hold, $hold->pickup_lib);
3503 $logger->info("circulator: running delayed hold notify process");
3505 # my $notifier = OpenILS::Application::Circ::HoldNotify->new(
3506 # hold_id => $holdid, editor => new_editor(requestor=>$self->editor->requestor));
3508 my $notifier = OpenILS::Application::Circ::HoldNotify->new(
3509 hold_id => $holdid, requestor => $self->editor->requestor);
3511 $logger->debug("circulator: built hold notifier");
3513 if(!$notifier->event) {
3515 $logger->info("circulator: attempt at sending hold notification for hold $holdid");
3517 my $stat = $notifier->send_email_notify;
3518 if( $stat == '1' ) {
3519 $logger->info("circulator: hold notify succeeded for hold $holdid");
3523 $logger->debug("circulator: * hold notify cancelled or failed for hold $holdid");
3526 $logger->info("circulator: Not sending hold notification since the patron has no email address");
3530 sub retarget_holds {
3532 $logger->info("circulator: retargeting holds @{$self->retarget} after opportunistic capture");
3533 my $ses = OpenSRF::AppSession->create('open-ils.hold-targeter');
3534 $ses->request('open-ils.hold-targeter.target', {hold => $self->retarget});
3535 # no reason to wait for the return value
3539 sub checkin_build_hold_transit {
3542 my $copy = $self->copy;
3543 my $hold = $self->hold;
3544 my $trans = Fieldmapper::action::hold_transit_copy->new;
3546 $logger->debug("circulator: building hold transit for ".$copy->barcode);
3548 $trans->hold($hold->id);
3549 $trans->source($self->circ_lib);
3550 $trans->dest($hold->pickup_lib);
3551 $trans->source_send_time("now");
3552 $trans->target_copy($copy->id);
3554 # when the copy gets to its destination, it will recover
3555 # this status - put it onto the holds shelf
3556 $trans->copy_status(OILS_COPY_STATUS_ON_HOLDS_SHELF);
3558 return $self->bail_on_events($self->editor->event)
3559 unless $self->editor->create_action_hold_transit_copy($trans);
3564 sub process_received_transit {
3566 my $copy = $self->copy;
3567 my $copyid = $self->copy->id;
3569 my $status_name = $U->copy_status($copy->status)->name;
3570 $logger->debug("circulator: attempting transit receive on ".
3571 "copy $copyid. Copy status is $status_name");
3573 my $transit = $self->transit;
3575 # Check if we are in a transit suppress range
3576 my $suppress_transit = 0;
3577 if ( $transit->dest != $self->circ_lib and not ( $self->hold_as_transit and $transit->copy_status == OILS_COPY_STATUS_ON_HOLDS_SHELF ) ) {
3578 my $suppress_setting = ($transit->copy_status == OILS_COPY_STATUS_ON_HOLDS_SHELF ? 'circ.transit.suppress_hold' : 'circ.transit.suppress_non_hold');
3579 my $suppress_transit_circ = $U->ou_ancestor_setting($self->circ_lib, $suppress_setting);
3580 if($suppress_transit_circ && $suppress_transit_circ->{value}) {
3581 my $suppress_transit_dest = $U->ou_ancestor_setting($transit->dest, $suppress_setting);
3582 if($suppress_transit_dest && $suppress_transit_dest->{value} eq $suppress_transit_circ->{value}) {
3583 $suppress_transit = 1;
3584 $self->fake_hold_dest(1) if $transit->copy_status == OILS_COPY_STATUS_ON_HOLDS_SHELF;
3588 if( not $suppress_transit and ( $transit->dest != $self->circ_lib or ($self->hold_as_transit && $transit->copy_status == OILS_COPY_STATUS_ON_HOLDS_SHELF) ) ) {
3589 # - this item is in-transit to a different location
3590 # - Or we are capturing holds as transits, so why create a new transit?
3592 my $tid = $transit->id;
3593 my $loc = $self->circ_lib;
3594 my $dest = $transit->dest;
3596 $logger->info("circulator: Fowarding transit on copy which is destined ".
3597 "for a different location. transit=$tid, copy=$copyid, current ".
3598 "location=$loc, destination location=$dest");
3600 my $evt = OpenILS::Event->new('ROUTE_ITEM', org => $dest, payload => {});
3602 # grab the associated hold object if available
3603 my $ht = $self->editor->retrieve_action_hold_transit_copy($tid);
3604 $self->hold($self->editor->retrieve_action_hold_request($ht->hold)) if $ht;
3606 return $self->bail_on_events($evt);
3609 # The transit is received, set the receive time
3610 $transit->dest_recv_time('now');
3611 $self->bail_on_events($self->editor->event)
3612 unless $self->editor->update_action_transit_copy($transit);
3614 my $hold_transit = $self->editor->retrieve_action_hold_transit_copy($transit->id);
3616 $logger->info("circulator: Recovering original copy status in transit: ".$transit->copy_status);
3617 $copy->status( $transit->copy_status );
3618 $self->update_copy();
3619 return if $self->bail_out;
3623 my $hold = $self->editor->retrieve_action_hold_request($hold_transit->hold);
3626 # hold has arrived at destination, set shelf time
3627 $self->put_hold_on_shelf($hold);
3628 $self->bail_on_events($self->editor->event)
3629 unless $self->editor->update_action_hold_request($hold);
3630 return if $self->bail_out;
3632 $self->notify_hold($hold_transit->hold);
3635 $hold_transit = undef;
3636 $self->cancelled_hold_transit(1);
3637 $self->reshelve_copy(1);
3638 $self->fake_hold_dest(0);
3643 OpenILS::Event->new(
3646 payload => { transit => $transit, holdtransit => $hold_transit } ));
3648 return $hold_transit;
3652 # ------------------------------------------------------------------
3653 # Sets the shelf_time and shelf_expire_time for a newly shelved hold
3654 # ------------------------------------------------------------------
3655 sub put_hold_on_shelf {
3656 my($self, $hold) = @_;
3657 $hold->shelf_time('now');
3658 $hold->current_shelf_lib($self->circ_lib);
3659 $holdcode->set_hold_shelf_expire_time($hold, $self->editor);
3665 my $reservation = shift;
3666 my $dt_parser = DateTime::Format::ISO8601->new;
3668 my $obj = $reservation ? $self->reservation : $self->circ;
3670 my $lost_bill_opts = $self->lost_bill_options;
3671 my $circ_lib = $lost_bill_opts->{circ_lib} if $lost_bill_opts;
3672 # first, restore any voided overdues for lost, if needed
3673 if ($self->needs_lost_bill_handling and !$self->void_overdues) {
3674 my $restore_od = $U->ou_ancestor_setting_value(
3675 $circ_lib, $lost_bill_opts->{ous_restore_overdue},
3676 $self->editor) || 0;
3677 $self->checkin_handle_lost_or_lo_now_found_restore_od($circ_lib)
3681 # next, handle normal overdue generation and apply stop_fines
3682 # XXX reservations don't have stop_fines
3683 # TODO revisit booking_reservation re: stop_fines support
3684 if ($reservation or !$obj->stop_fines) {
3687 # This is a crude check for whether we are in a grace period. The code
3688 # in generate_fines() does a more thorough job, so this exists solely
3689 # as a small optimization, and might be better off removed.
3691 # If we have a grace period
3692 if($obj->can('grace_period')) {
3693 # Parse out the due date
3694 my $due_date = $dt_parser->parse_datetime( clean_ISO8601($obj->due_date) );
3695 # Add the grace period to the due date
3696 $due_date->add(seconds => OpenILS::Utils::DateTime->interval_to_seconds($obj->grace_period));
3697 # Don't generate fines on circs still in grace period
3698 $skip_for_grace = $due_date > DateTime->now;
3700 $CC->generate_fines({circs => [$obj], editor => $self->editor})
3701 unless $skip_for_grace;
3703 if (!$reservation and !$obj->stop_fines) {
3704 $obj->stop_fines(OILS_STOP_FINES_CHECKIN);
3705 $obj->stop_fines(OILS_STOP_FINES_RENEW) if $self->is_renewal;
3706 $obj->stop_fines(OILS_STOP_FINES_CLAIMS_NEVERCHECKEDOUT) if $self->claims_never_checked_out;
3707 $obj->stop_fines_time('now');
3708 $obj->stop_fines_time($self->backdate) if $self->backdate;
3709 $self->editor->update_action_circulation($obj);
3713 # finally, handle voiding of lost item and processing fees
3714 if ($self->needs_lost_bill_handling) {
3715 my $void_cost = $U->ou_ancestor_setting_value(
3716 $circ_lib, $lost_bill_opts->{ous_void_item_cost},
3717 $self->editor) || 0;
3718 my $void_proc_fee = $U->ou_ancestor_setting_value(
3719 $circ_lib, $lost_bill_opts->{ous_void_proc_fee},
3720 $self->editor) || 0;
3721 $self->checkin_handle_lost_or_lo_now_found(
3722 $lost_bill_opts->{void_cost_btype},
3723 $lost_bill_opts->{is_longoverdue}) if $void_cost;
3724 $self->checkin_handle_lost_or_lo_now_found(
3725 $lost_bill_opts->{void_fee_btype},
3726 $lost_bill_opts->{is_longoverdue}) if $void_proc_fee;
3732 sub checkin_handle_circ_start {
3734 my $circ = $self->circ;
3735 my $copy = $self->copy;
3739 $self->backdate($circ->xact_start) if $self->claims_never_checked_out;
3741 # backdate the circ if necessary
3742 if($self->backdate) {
3743 my $evt = $self->checkin_handle_backdate;
3744 return $self->bail_on_events($evt) if $evt;
3747 # Set the checkin vars since we have the item
3748 $circ->checkin_time( ($self->backdate) ? $self->backdate : 'now' );
3750 # capture the true scan time for back-dated checkins
3751 $circ->checkin_scan_time('now');
3753 $circ->checkin_staff($self->editor->requestor->id);
3754 $circ->checkin_lib($self->circ_lib);
3755 $circ->checkin_workstation($self->editor->requestor->wsid);
3757 my $circ_lib = (ref $self->copy->circ_lib) ?
3758 $self->copy->circ_lib->id : $self->copy->circ_lib;
3759 my $stat = $U->copy_status($self->copy->status)->id;
3761 if ($stat == OILS_COPY_STATUS_LOST || $stat == OILS_COPY_STATUS_LOST_AND_PAID) {
3762 # we will now handle lost fines, but the copy will retain its 'lost'
3763 # status if it needs to transit home unless lost_immediately_available
3766 # if we decide to also delay fine handling until the item arrives home,
3767 # we will need to call lost fine handling code both when checking items
3768 # in and also when receiving transits
3769 $self->checkin_handle_lost($circ_lib);
3770 } elsif ($stat == OILS_COPY_STATUS_LONG_OVERDUE) {
3771 # same process as above.
3772 $self->checkin_handle_long_overdue($circ_lib);
3773 } elsif ($circ_lib != $self->circ_lib and $stat == OILS_COPY_STATUS_MISSING) {
3774 $logger->info("circulator: not updating copy status on checkin because copy is missing");
3776 my $next_status = $self->next_copy_status->[0] || OILS_COPY_STATUS_RESHELVING;
3777 $self->copy->status($U->copy_status($next_status));
3784 sub checkin_handle_circ_finish {
3786 my $e = $self->editor;
3787 my $circ = $self->circ;
3789 # Do one last check before the final circulation update to see
3790 # if the xact_finish value should be set or not.
3792 # The underlying money.billable_xact may have been updated to
3793 # reflect a change in xact_finish during checkin bills handling,
3794 # however we can't simply refresh the circulation from the DB,
3795 # because other changes may be pending. Instead, reproduce the
3796 # xact_finish check here. It won't hurt to do it again.
3798 my $sum = $e->retrieve_money_billable_transaction_summary($circ->id);
3799 if ($sum) { # is this test still needed?
3801 my $balance = $sum->balance_owed;
3803 if ($balance == 0) {
3804 $circ->xact_finish('now');
3806 $circ->clear_xact_finish;
3809 $logger->info("circulator: $balance is owed on this circulation");
3812 return $self->bail_on_events($e->event)
3813 unless $e->update_action_circulation($circ);
3818 # ------------------------------------------------------------------
3819 # See if we need to void billings, etc. for lost checkin
3820 # ------------------------------------------------------------------
3821 sub checkin_handle_lost {
3823 my $circ_lib = shift;
3825 my $max_return = $U->ou_ancestor_setting_value($circ_lib,
3826 OILS_SETTING_MAX_ACCEPT_RETURN_OF_LOST, $self->editor) || 0;
3828 $self->lost_bill_options({
3829 circ_lib => $circ_lib,
3830 ous_void_item_cost => OILS_SETTING_VOID_LOST_ON_CHECKIN,
3831 ous_void_proc_fee => OILS_SETTING_VOID_LOST_PROCESS_FEE_ON_CHECKIN,
3832 ous_restore_overdue => OILS_SETTING_RESTORE_OVERDUE_ON_LOST_RETURN,
3833 void_cost_btype => 3,
3837 return $self->checkin_handle_lost_or_longoverdue(
3838 circ_lib => $circ_lib,
3839 max_return => $max_return,
3840 ous_immediately_available => OILS_SETTING_LOST_IMMEDIATELY_AVAILABLE,
3841 ous_use_last_activity => undef # not supported for LOST checkin
3845 # ------------------------------------------------------------------
3846 # See if we need to void billings, etc. for long-overdue checkin
3847 # note: not using constants below since they serve little purpose
3848 # for single-use strings that are descriptive in their own right
3849 # and mostly just complicate debugging.
3850 # ------------------------------------------------------------------
3851 sub checkin_handle_long_overdue {
3853 my $circ_lib = shift;
3855 $logger->info("circulator: processing long-overdue checkin...");
3857 my $max_return = $U->ou_ancestor_setting_value($circ_lib,
3858 'circ.max_accept_return_of_longoverdue', $self->editor) || 0;
3860 $self->lost_bill_options({
3861 circ_lib => $circ_lib,
3862 ous_void_item_cost => 'circ.void_longoverdue_on_checkin',
3863 ous_void_proc_fee => 'circ.void_longoverdue_proc_fee_on_checkin',
3864 is_longoverdue => 1,
3865 ous_restore_overdue => 'circ.restore_overdue_on_longoverdue_return',
3866 void_cost_btype => 10,
3867 void_fee_btype => 11
3870 return $self->checkin_handle_lost_or_longoverdue(
3871 circ_lib => $circ_lib,
3872 max_return => $max_return,
3873 ous_immediately_available => 'circ.longoverdue_immediately_available',
3874 ous_use_last_activity =>
3875 'circ.longoverdue.use_last_activity_date_on_return'
3879 # last billing activity is last payment time, last billing time, or the
3880 # circ due date. If the relevant "use last activity" org unit setting is
3881 # false/unset, then last billing activity is always the due date.
3882 sub get_circ_last_billing_activity {
3884 my $circ_lib = shift;
3885 my $setting = shift;
3886 my $date = $self->circ->due_date;
3888 return $date unless $setting and
3889 $U->ou_ancestor_setting_value($circ_lib, $setting, $self->editor);
3891 my $xact = $self->editor->retrieve_money_billable_transaction([
3893 {flesh => 1, flesh_fields => {mbt => ['summary']}}
3896 if ($xact->summary) {
3897 $date = $xact->summary->last_payment_ts ||
3898 $xact->summary->last_billing_ts ||
3899 $self->circ->due_date;
3906 sub checkin_handle_lost_or_longoverdue {
3907 my ($self, %args) = @_;
3909 my $circ = $self->circ;
3910 my $max_return = $args{max_return};
3911 my $circ_lib = $args{circ_lib};
3916 $self->get_circ_last_billing_activity(
3917 $circ_lib, $args{ous_use_last_activity});
3920 my @tm = reverse($last_activity =~ /([\d\.]+)/og);
3921 $tm[5] -= 1 if $tm[5] > 0;
3922 my $due = timelocal(int($tm[1]), int($tm[2]),
3923 int($tm[3]), int($tm[4]), int($tm[5]), int($tm[6]));
3926 OpenILS::Utils::DateTime->interval_to_seconds($max_return) + int($due);
3928 $logger->info("MAX OD: $max_return LAST ACTIVITY: ".
3929 "$last_activity DUEDATE: ".$circ->due_date." TODAY: $today ".
3930 "DUE: $due LAST: $last_chance");
3932 $max_return = 0 if $today < $last_chance;
3938 $logger->info("circulator: check-in of lost/lo item exceeds max ".
3939 "return interval. skipping fine/fee voiding, etc.");
3941 } elsif ($self->dont_change_lost_zero) { # we leave lost zero balance alone
3943 $logger->info("circulator: check-in of lost/lo item having a balance ".
3944 "of zero, skipping fine/fee voiding and reinstatement.");
3946 } else { # within max-return interval or no interval defined
3948 $logger->info("circulator: check-in of lost/lo item is within the ".
3949 "max return interval (or no interval is defined). Proceeding ".
3950 "with fine/fee voiding, etc.");
3952 $self->needs_lost_bill_handling(1);
3955 if ($circ_lib != $self->circ_lib) {
3956 # if the item is not home, check to see if we want to retain the
3957 # lost/longoverdue status at this point in the process
3959 my $immediately_available = $U->ou_ancestor_setting_value($circ_lib,
3960 $args{ous_immediately_available}, $self->editor) || 0;
3962 if ($immediately_available) {
3963 # item status does not need to be retained, so give it a
3964 # reshelving status as if it were a normal checkin
3965 my $next_status = $self->next_copy_status->[0] || OILS_COPY_STATUS_RESHELVING;
3966 $self->copy->status($U->copy_status($next_status));
3969 $logger->info("circulator: leaving lost/longoverdue copy".
3970 " status in place on checkin");
3973 # lost/longoverdue item is home and processed, treat like a normal
3974 # checkin from this point on
3975 my $next_status = $self->next_copy_status->[0] || OILS_COPY_STATUS_RESHELVING;
3976 $self->copy->status($U->copy_status($next_status));
3982 sub checkin_handle_backdate {
3985 # ------------------------------------------------------------------
3986 # clean up the backdate for date comparison
3987 # XXX We are currently taking the due-time from the original due-date,
3988 # not the input. Do we need to do this? This certainly interferes with
3989 # backdating of hourly checkouts, but that is likely a very rare case.
3990 # ------------------------------------------------------------------
3991 my $bd = clean_ISO8601($self->backdate);
3992 my $original_date = DateTime::Format::ISO8601->new->parse_datetime(clean_ISO8601($self->circ->due_date));
3993 my $new_date = DateTime::Format::ISO8601->new->parse_datetime($bd);
3994 $new_date->set_hour($original_date->hour());
3995 $new_date->set_minute($original_date->minute());
3996 if ($new_date >= DateTime->now) {
3997 # We can't say that the item will be checked in later...so assume someone's clock is wrong instead.
3998 # $self->backdate() autoload handler ignores undef values.
3999 # Clear the backdate manually.
4000 $logger->info("circulator: ignoring future backdate: $new_date");
4001 delete $self->{backdate};
4003 $self->backdate(clean_ISO8601($new_date->datetime()));
4010 sub check_checkin_copy_status {
4012 my $copy = $self->copy;
4014 my $status = $U->copy_status($copy->status)->id;
4017 if( $self->new_copy_alerts ||
4018 $status == OILS_COPY_STATUS_AVAILABLE ||
4019 $status == OILS_COPY_STATUS_CHECKED_OUT ||
4020 $status == OILS_COPY_STATUS_IN_PROCESS ||
4021 $status == OILS_COPY_STATUS_ON_HOLDS_SHELF ||
4022 $status == OILS_COPY_STATUS_IN_TRANSIT ||
4023 $status == OILS_COPY_STATUS_CATALOGING ||
4024 $status == OILS_COPY_STATUS_ON_RESV_SHELF ||
4025 $status == OILS_COPY_STATUS_CANCELED_TRANSIT ||
4026 $status == OILS_COPY_STATUS_RESHELVING );
4028 return OpenILS::Event->new('COPY_STATUS_LOST', payload => $copy )
4029 if( $status == OILS_COPY_STATUS_LOST );
4031 return OpenILS::Event->new('COPY_STATUS_LOST_AND_PAID', payload => $copy)
4032 if ($status == OILS_COPY_STATUS_LOST_AND_PAID);
4034 return OpenILS::Event->new('COPY_STATUS_LONG_OVERDUE', payload => $copy )
4035 if( $status == OILS_COPY_STATUS_LONG_OVERDUE );
4037 return OpenILS::Event->new('COPY_STATUS_MISSING', payload => $copy )
4038 if( $status == OILS_COPY_STATUS_MISSING );
4040 return OpenILS::Event->new('COPY_BAD_STATUS', payload => $copy );
4045 # --------------------------------------------------------------------------
4046 # On checkin, we need to return as many relevant objects as we can
4047 # --------------------------------------------------------------------------
4048 sub checkin_flesh_events {
4051 if( grep { $_->{textcode} eq 'SUCCESS' } @{$self->events}
4052 and grep { $_->{textcode} eq 'ITEM_NOT_CATALOGED' } @{$self->events} ) {
4053 $self->events([grep { $_->{textcode} eq 'ITEM_NOT_CATALOGED' } @{$self->events}]);
4056 my $record = $U->record_to_mvr($self->title) if($self->title and !$self->is_precat);
4059 if($self->hold and !$self->hold->cancel_time) {
4060 $hold = $self->hold;
4061 $hold->notes($self->editor->search_action_hold_request_note({hold => $hold->id}));
4065 # update our copy of the circ object and
4066 # flesh the billing summary data
4068 $self->editor->retrieve_action_circulation([
4072 circ => ['billable_transaction'],
4081 # flesh some patron fields before returning
4083 $self->editor->retrieve_actor_user([
4088 au => ['card', 'billing_address', 'mailing_address']
4095 # Flesh the latest inventory.
4096 # NB: This survives the unflesh_copy below. Let's keep it that way.
4097 my $alci = $self->editor->search_asset_latest_inventory([
4098 {copy=>$self->copy->id},
4101 alci => ['inventory_workstation']
4103 if ($alci && $alci->[0]) {
4104 $self->copy->latest_inventory($alci->[0]);
4107 for my $evt (@{$self->events}) {
4110 $payload->{copy} = $U->unflesh_copy($self->copy);
4111 $payload->{volume} = $self->volume;
4112 $payload->{record} = $record,
4113 $payload->{circ} = $self->circ;
4114 $payload->{transit} = $self->transit;
4115 $payload->{cancelled_hold_transit} = 1 if $self->cancelled_hold_transit;
4116 $payload->{hold} = $hold;
4117 $payload->{patron} = $self->patron;
4118 $payload->{reservation} = $self->reservation
4119 unless (not $self->reservation or $self->reservation->cancel_time);
4121 $evt->{payload} = $payload;
4126 my( $self, $msg ) = @_;
4127 my $bc = ($self->copy) ? $self->copy->barcode :
4128 $self->copy_barcode;
4130 my $usr = ($self->patron) ? $self->patron->id : "";
4131 $logger->info("circulator: $msg requestor=".$self->editor->requestor->id.
4132 ", recipient=$usr, copy=$bc");
4139 $self->log_me("do_renew()");
4141 # Make sure there is an open circ to renew
4142 my $usrid = $self->patron->id if $self->patron;
4143 my $circ = $self->editor->search_action_circulation({
4144 target_copy => $self->copy->id,
4145 xact_finish => undef,
4146 checkin_time => undef,
4147 ($usrid ? (usr => $usrid) : ())
4150 return $self->bail_on_events($self->editor->event) unless $circ;
4152 # A user is not allowed to renew another user's items without permission
4153 unless( $circ->usr eq $self->editor->requestor->id ) {
4154 return $self->bail_on_events($self->editor->events)
4155 unless $self->editor->allowed('RENEW_CIRC', $circ->circ_lib);
4158 $self->push_events(OpenILS::Event->new('MAX_RENEWALS_REACHED'))
4159 if $circ->renewal_remaining < 1;
4161 $self->push_events(OpenILS::Event->new('MAX_AUTO_RENEWALS_REACHED'))
4162 if $self->auto_renewal and $circ->auto_renewal_remaining < 1;
4163 # -----------------------------------------------------------------
4165 $self->parent_circ($circ->id);
4166 $self->renewal_remaining( $circ->renewal_remaining - 1 );
4167 $self->auto_renewal_remaining( $circ->auto_renewal_remaining - 1 ) if (defined($circ->auto_renewal_remaining));
4170 # Opac renewal - re-use circ library from original circ (unless told not to)
4171 if($self->opac_renewal or $self->auto_renewal) {
4172 unless(defined($opac_renewal_use_circ_lib)) {
4173 my $use_circ_lib = $self->editor->retrieve_config_global_flag('circ.opac_renewal.use_original_circ_lib');
4174 if($use_circ_lib and $U->is_true($use_circ_lib->enabled)) {
4175 $opac_renewal_use_circ_lib = 1;
4178 $opac_renewal_use_circ_lib = 0;
4181 $self->circ_lib($circ->circ_lib) if($opac_renewal_use_circ_lib);
4184 # Desk renewal - re-use circ library from original circ (unless told not to)
4185 if($self->desk_renewal) {
4186 unless(defined($desk_renewal_use_circ_lib)) {
4187 my $use_circ_lib = $self->editor->retrieve_config_global_flag('circ.desk_renewal.use_original_circ_lib');
4188 if($use_circ_lib and $U->is_true($use_circ_lib->enabled)) {
4189 $desk_renewal_use_circ_lib = 1;
4192 $desk_renewal_use_circ_lib = 0;
4195 $self->circ_lib($circ->circ_lib) if($desk_renewal_use_circ_lib);
4198 # Check if expired patron is allowed to renew, and bail if not.
4199 my $expire = DateTime::Format::ISO8601->new->parse_datetime(clean_ISO8601($self->patron->expire_date));
4200 if (CORE::time > $expire->epoch) {
4201 my $allow_renewal = $U->ou_ancestor_setting_value($self->circ_lib, OILS_SETTING_ALLOW_RENEW_FOR_EXPIRED_PATRON);
4202 unless ($U->is_true($allow_renewal)) {
4203 return $self->bail_on_events(OpenILS::Event->new('PATRON_ACCOUNT_EXPIRED'));
4207 # Run the fine generator against the old circ
4208 # XXX This seems unnecessary, given that handle_fines runs in do_checkin
4209 # a few lines down. Commenting out, for now.
4210 #$self->handle_fines;
4212 $self->run_renew_permit;
4215 $self->do_checkin();
4216 return if $self->bail_out;
4218 unless( $self->permit_override ) {
4220 return if $self->bail_out;
4221 $self->is_precat(1) if $self->have_event('ITEM_NOT_CATALOGED');
4222 $self->remove_event('ITEM_NOT_CATALOGED');
4225 $self->override_events;
4226 return if $self->bail_out;
4229 $self->do_checkout();
4234 my( $self, $evt ) = @_;
4235 $evt = (ref $evt) ? $evt->{textcode} : $evt;
4236 $logger->debug("circulator: removing event from list: $evt");
4237 my @events = @{$self->events};
4238 $self->events( [ grep { $_->{textcode} ne $evt } @events ] );
4243 my( $self, $evt ) = @_;
4244 $evt = (ref $evt) ? $evt->{textcode} : $evt;
4245 return grep { $_->{textcode} eq $evt } @{$self->events};
4249 sub run_renew_permit {
4252 if ($U->ou_ancestor_setting_value($self->circ_lib, 'circ.block_renews_for_holds')) {
4253 my ($hold, undef, $retarget) = $holdcode->find_nearest_permitted_hold(
4254 $self->editor, $self->copy, $self->editor->requestor, 1
4256 $self->push_events(new OpenILS::Event("COPY_NEEDED_FOR_HOLD")) if $hold;
4259 my $results = $self->run_indb_circ_test;
4260 $self->push_events($self->matrix_test_result_events)
4261 unless $self->circ_test_success;
4265 # XXX: The primary mechanism for storing circ history is now handled
4266 # by tracking real circulation objects instead of bibs in a bucket.
4267 # However, this code is disabled by default and could be useful
4268 # some day, so may as well leave it for now.
4269 sub append_reading_list {
4273 $self->is_checkout and
4279 # verify history is globally enabled and uses the bucket mechanism
4280 my $htype = OpenSRF::Utils::SettingsClient->new->config_value(
4281 apps => 'open-ils.circ' => app_settings => 'checkout_history_mechanism');
4283 return undef unless $htype and $htype eq 'bucket';
4285 my $e = new_editor(xact => 1, requestor => $self->editor->requestor);
4287 # verify the patron wants to retain the hisory
4288 my $setting = $e->search_actor_user_setting(
4289 {usr => $self->patron->id, name => 'circ.keep_checkout_history'})->[0];
4291 unless($setting and $setting->value) {
4296 my $bkt = $e->search_container_copy_bucket(
4297 {owner => $self->patron->id, btype => 'circ_history'})->[0];
4302 # find the next item position
4303 my $last_item = $e->search_container_copy_bucket_item(
4304 {bucket => $bkt->id}, {order_by => {ccbi => 'pos desc'}, limit => 1})->[0];
4305 $pos = $last_item->pos + 1 if $last_item;
4308 # create the history bucket if necessary
4309 $bkt = Fieldmapper::container::copy_bucket->new;
4310 $bkt->owner($self->patron->id);
4312 $bkt->btype('circ_history');
4314 $e->create_container_copy_bucket($bkt) or return $e->die_event;
4317 my $item = Fieldmapper::container::copy_bucket_item->new;
4319 $item->bucket($bkt->id);
4320 $item->target_copy($self->copy->id);
4323 $e->create_container_copy_bucket_item($item) or return $e->die_event;
4330 sub make_trigger_events {
4332 return unless $self->circ;
4333 $U->create_events_for_hook('checkout', $self->circ, $self->circ_lib) if $self->is_checkout;
4334 $U->create_events_for_hook('checkin', $self->circ, $self->circ_lib) if $self->is_checkin;
4335 $U->create_events_for_hook('renewal', $self->circ, $self->circ_lib) if $self->is_renewal;
4340 sub checkin_handle_lost_or_lo_now_found {
4341 my ($self, $bill_type, $is_longoverdue) = @_;
4343 my $tag = $is_longoverdue ? "LONGOVERDUE" : "LOST";
4345 $logger->debug("voiding $tag item billings");
4346 my $result = $CC->void_or_zero_bills_of_type($self->editor, $self->circ, $self->copy, $bill_type, "$tag ITEM RETURNED");
4347 $self->bail_on_events($self->editor->event) if ($result);
4350 sub checkin_handle_lost_or_lo_now_found_restore_od {
4352 my $circ_lib = shift;
4353 my $is_longoverdue = shift;
4354 my $tag = $is_longoverdue ? "LONGOVERDUE" : "LOST";
4356 # ------------------------------------------------------------------
4357 # restore those overdue charges voided when item was set to lost
4358 # ------------------------------------------------------------------
4360 my $ods = $self->editor->search_money_billing([
4362 xact => $self->circ->id,
4366 order_by => {mb => 'billing_ts desc'}
4370 $logger->debug("returning ".scalar(@$ods)." overdue charges pre-$tag");
4371 # Because actual users get up to all kinds of unexpectedness, we
4372 # only recreate up to $circ->max_fine in bills. I know you think
4373 # it wouldn't happen that bills could get created, voided, and
4374 # recreated more than once, but I guaran-damn-tee you that it will
4376 if ($ods && @$ods) {
4377 my $void_amount = 0;
4378 my $void_max = $self->circ->max_fine();
4379 # search for overdues voided the new way (aka "adjusted")
4380 my @billings = map {$_->id()} @$ods;
4381 my $voids = $self->editor->search_money_account_adjustment(
4383 billing => \@billings
4387 map {$void_amount += $_->amount()} @$voids;
4389 # if no adjustments found, assume they were voided the old way (aka "voided")
4390 for my $bill (@$ods) {
4391 if( $U->is_true($bill->voided) ) {
4392 $void_amount += $bill->amount();
4398 ($void_amount < $void_max ? $void_amount : $void_max),
4400 $ods->[0]->billing_type(),
4402 "System: $tag RETURNED - OVERDUES REINSTATED",
4403 $ods->[-1]->period_start(),
4404 $ods->[0]->period_end() # date this restoration the same as the last overdue (for possible subsequent fine generation)